#!/usr/bin/env bash ######################################################################################### #### container-update-checker.sh — Check Docker/Podman containers for image updates #### #### Compares local image digests against remote registry digests #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./container-update-checker.sh #### #### ./container-update-checker.sh --docker --filter nginx #### #### ./container-update-checker.sh --json --quiet #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── RUNTIME="${CONTAINER_RUNTIME:-auto}" TIMEOUT="${REGISTRY_TIMEOUT:-10}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" JSON_OUTPUT="false" QUIET="false" FILTER="" LABEL="" TEXTFILE_DIR="/var/lib/node_exporter" PROM_FILE="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME COUNT_CURRENT=0 COUNT_UPDATE=0 COUNT_ERROR=0 COUNT_TOTAL=0 JSON_ITEMS="" PROM_LINES="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*" >&2; fi; } # ── Runtime Detection ───────────────────────────────────────────────── detect_runtime() { if [[ "$RUNTIME" == "docker" || "$RUNTIME" == "podman" ]]; then if ! command -v "$RUNTIME" &>/dev/null; then err "${RUNTIME^} not found"; exit 2 fi return fi if command -v docker &>/dev/null && docker info &>/dev/null; then RUNTIME="docker" elif command -v podman &>/dev/null; then RUNTIME="podman" else err "Neither Docker nor Podman found"; exit 2 fi verbose "Auto-detected runtime: ${RUNTIME}" } # ── Auth Helper ─────────────────────────────────────────────────────── get_auth_header() { local registry="$1" config_file="" if [[ "$RUNTIME" == "podman" ]]; then config_file="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/containers/auth.json" [[ -f "$config_file" ]] || config_file="${HOME}/.config/containers/auth.json" fi [[ -f "${config_file:-}" ]] || config_file="${HOME}/.docker/config.json" [[ -f "$config_file" ]] || return 0 local auth auth=$(grep -A1 "\"${registry}\"" "$config_file" 2>/dev/null \ | grep '"auth"' | head -1 | sed 's/.*"auth"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') || true if [[ -n "$auth" ]]; then echo "Authorization: Basic ${auth}" fi } # ── Parse Image Reference ──────────────────────────────────────────── parse_image_ref() { local image="$1" registry="" path="" tag="" local without_tag="${image%%@*}" if [[ "$without_tag" == *:* && "${without_tag##*:}" != */* ]]; then tag="${without_tag##*:}" without_tag="${without_tag%:*}" fi [[ -z "$tag" ]] && tag="latest" if [[ "$without_tag" == *"."*"/"* ]] || [[ "$without_tag" == *":"*"/"* ]] || [[ "$without_tag" == "localhost/"* ]]; then registry="${without_tag%%/*}" path="${without_tag#*/}" else registry="docker.io" [[ "$without_tag" == *"/"* ]] && path="$without_tag" || path="library/${without_tag}" fi echo "${registry}" "${path}" "${tag}" } # ── Get Local Digest ───────────────────────────────────────────────── get_local_digest() { local image="$1" digest digest=$($RUNTIME image inspect "$image" --format '{{index .RepoDigests 0}}' 2>/dev/null) || true if [[ -n "$digest" && "$digest" == *"@"* ]]; then echo "${digest##*@}"; return fi digest=$($RUNTIME image inspect "$image" --format '{{.Id}}' 2>/dev/null) || true echo "${digest:-}" } # ── Extract JSON Value (pure bash, no python/jq) ───────────────────── json_value() { local key="$1" sed -n "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1 } # ── Get Remote Digest via Skopeo ────────────────────────────────────── get_remote_digest_skopeo() { local registry="$1" path="$2" tag="$3" local digest digest=$(timeout "$TIMEOUT" skopeo inspect --no-tags "docker://${registry}/${path}:${tag}" 2>/dev/null \ | json_value "Digest") || true echo "${digest:-}" } # ── Get Remote Digest via Curl ──────────────────────────────────────── get_remote_digest_curl() { local registry="$1" path="$2" tag="$3" local token="" digest="" if [[ "$registry" == "docker.io" || "$registry" == "registry-1.docker.io" ]]; then token=$(curl -sf --max-time "$TIMEOUT" \ "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${path}:pull" \ | json_value "token") || true [[ -z "$token" ]] && return digest=$(curl -sf --max-time "$TIMEOUT" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ -H "Accept: application/vnd.oci.image.index.v1+json" \ -H "Authorization: Bearer ${token}" \ "https://registry-1.docker.io/v2/${path}/manifests/${tag}" \ -o /dev/null -D - 2>/dev/null \ | grep -i "docker-content-digest" | tr -d '\r' | awk '{print $2}') || true else local auth_hdr auth_args=() auth_hdr=$(get_auth_header "$registry") [[ -n "$auth_hdr" ]] && auth_args=(-H "$auth_hdr") digest=$(curl -sf --max-time "$TIMEOUT" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ -H "Accept: application/vnd.oci.image.index.v1+json" \ "${auth_args[@]+"${auth_args[@]}"}" \ "https://${registry}/v2/${path}/manifests/${tag}" \ -o /dev/null -D - 2>/dev/null \ | grep -i "docker-content-digest" | tr -d '\r' | awk '{print $2}') || true fi echo "${digest:-}" } # ── Get Remote Digest (skopeo then curl fallback) ───────────────────── get_remote_digest() { local registry="$1" path="$2" tag="$3" digest="" if command -v skopeo &>/dev/null; then verbose "Trying skopeo for ${registry}/${path}:${tag}" digest=$(get_remote_digest_skopeo "$registry" "$path" "$tag") fi if [[ -z "$digest" ]]; then verbose "Trying curl fallback for ${registry}/${path}:${tag}" digest=$(get_remote_digest_curl "$registry" "$path" "$tag") fi echo "${digest:-}" } # ── Check Single Container ──────────────────────────────────────────── check_container() { local name="$1" image="$2" local status="" local_digest="" remote_digest="" registry path tag read -r registry path tag <<< "$(parse_image_ref "$image")" verbose "Container=${name} image=${image} registry=${registry} path=${path} tag=${tag}" local_digest=$(get_local_digest "$image") verbose "Local digest: ${local_digest:-none}" if [[ -z "$local_digest" ]]; then status="error" else remote_digest=$(get_remote_digest "$registry" "$path" "$tag") verbose "Remote digest: ${remote_digest:-none}" if [[ -z "$remote_digest" ]]; then status="error" elif [[ "$local_digest" == "$remote_digest" ]]; then status="current" else status="update" fi fi COUNT_TOTAL=$((COUNT_TOTAL + 1)) case "$status" in current) COUNT_CURRENT=$((COUNT_CURRENT + 1)) ;; update) COUNT_UPDATE=$((COUNT_UPDATE + 1)) ;; error) COUNT_ERROR=$((COUNT_ERROR + 1)) ;; esac if [[ -n "$PROM_FILE" ]]; then local val=1; [[ "$status" == "update" ]] && val=0 PROM_LINES+="container_image_up_to_date{name=\"${name}\",image=\"${image}\"} ${val}"$'\n' fi [[ "$QUIET" == "true" && "$status" != "update" ]] && return if [[ "$JSON_OUTPUT" == "true" ]]; then local item item=$(printf '{"container":"%s","image":"%s","status":"%s"}' "$name" "$image" "$status") [[ -n "$JSON_ITEMS" ]] && JSON_ITEMS="${JSON_ITEMS},${item}" || JSON_ITEMS="${item}" else local color symbol case "$status" in current) color="$GREEN"; symbol="up-to-date" ;; update) color="$YELLOW"; symbol="update available" ;; error) color="$RED"; symbol="check failed" ;; *) color=""; symbol="?" ;; esac printf " %-30s %-40s %b%s%b\n" "$name" "$image" "$color" "$symbol" "$RESET" fi } # ── List Containers ─────────────────────────────────────────────────── list_containers() { local filter_args=() [[ -n "$LABEL" ]] && filter_args+=(--filter "label=${LABEL}") $RUNTIME ps --format '{{.Names}}\t{{.Image}}' "${filter_args[@]}" 2>/dev/null } # ── Write Prometheus Metrics ────────────────────────────────────────── write_prom_metrics() { local file="$1" local output_dir output_dir="$(dirname "$file")" mkdir -p "$output_dir" local tmp tmp=$(mktemp "${output_dir}/.container_updates.XXXXXX") { echo "# HELP container_image_up_to_date Whether the container image is up to date (1=yes, 0=no)" echo "# TYPE container_image_up_to_date gauge" printf '%s' "$PROM_LINES" echo "# HELP container_update_check_timestamp Unix timestamp of last update check" echo "# TYPE container_update_check_timestamp gauge" echo "container_update_check_timestamp $(date +%s)" echo "# HELP container_update_check_total Total containers checked" echo "# TYPE container_update_check_total gauge" echo "container_update_check_total ${COUNT_TOTAL}" echo "# HELP container_update_available_total Containers with updates available" echo "# TYPE container_update_available_total gauge" echo "container_update_available_total ${COUNT_UPDATE}" } > "$tmp" chmod 644 "$tmp" mv -f "$tmp" "$file" verbose "Metrics written to ${file}" } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 2 ;; *) err "Unexpected argument: $1" echo "Run ${SCRIPT_NAME} --help for usage" >&2 exit 2 ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors detect_runtime local containers=() while IFS=$'\t' read -r name image; do [[ -z "$name" ]] && continue [[ -n "$FILTER" && "$name" != *"${FILTER}"* ]] && continue containers+=("${name} ${image}") done < <(list_containers) if [[ ${#containers[@]} -eq 0 ]]; then if [[ "$JSON_OUTPUT" == "true" ]]; then echo '{"results":[],"summary":{"total":0,"current":0,"update_available":0,"errors":0}}' else warn "No running containers found" fi exit 0 fi verbose "Found ${#containers[@]} containers to check" if [[ "$JSON_OUTPUT" != "true" ]]; then echo "" echo -e "${BOLD}Container Update Checker${RESET}" echo -e "${DIM}Runtime: ${RUNTIME} | Timeout: ${TIMEOUT}s${RESET}" echo "" printf " ${BOLD}%-30s %-40s %s${RESET}\n" "CONTAINER" "IMAGE" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..82})" fi for entry in "${containers[@]}"; do check_container "${entry%% *}" "${entry#* }" done if [[ "$JSON_OUTPUT" == "true" ]]; then printf '{"results":[%s],"summary":{"total":%d,"current":%d,"update_available":%d,"errors":%d}}\n' \ "$JSON_ITEMS" "$COUNT_TOTAL" "$COUNT_CURRENT" "$COUNT_UPDATE" "$COUNT_ERROR" else echo "" echo -e " ${BOLD}Summary${RESET}" printf " %-20s %d\n" "Total checked:" "$COUNT_TOTAL" printf " %-20s %b%d%b\n" "Up-to-date:" "$GREEN" "$COUNT_CURRENT" "$RESET" printf " %-20s %b%d%b\n" "Update available:" "$YELLOW" "$COUNT_UPDATE" "$RESET" printf " %-20s %b%d%b\n" "Errors:" "$RED" "$COUNT_ERROR" "$RESET" echo "" fi [[ -n "$PROM_FILE" ]] && write_prom_metrics "$PROM_FILE" if [[ "$COUNT_ERROR" -gt 0 ]]; then exit 2 elif [[ "$COUNT_UPDATE" -gt 0 ]]; then exit 1 fi exit 0 } main "$@"