#!/usr/bin/env bash # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2026 Bryan Joshua Pedini # tests/docker/cleanup.sh - restore the Docker image set recorded by snapshot.sh. # # Algorithm, in this order: # a. read the "before" maps from the snapshot; compute the "after" maps the same way # b. re-tag stolen names: every pre-existing repo:tag that now points at another # image (or vanished) is pointed back with `docker tag ` # (e.g. a pre-existing archlinux:latest whose tag was taken by a newer pull) # c. untag new names: every repo:tag not in the snapshot -> `docker rmi --no-prune` # (only untags when the image has other names; deletes it on its last name) # d. delete new ids: every image id not in the snapshot -> `docker rmi -f --no-prune` # e. print a summary; exit 1 if a pre-existing repo:tag is missing or points elsewhere # # Safety rules: # - containers are never stopped or removed; an image used by ANY container # (running or stopped) is left in place and reported as left over; # - an image id that is in the snapshot is never deleted: --no-prune keeps # untagged parents, and the last name of a pre-existing image is never removed; # - idempotent: running it again, or when nothing changed, is a no-op. # # usage: cleanup.sh [--dry-run] # exit: 0 ok, 1 a pre-existing repo:tag is still missing/wrong, 2 usage/environment error set -euo pipefail prog=${0##*/} here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) tags_file=$here/images-before.tsv ids_file=$here/image-ids-before.txt usage() { cat <&2; usage >&2; exit 2 ;; esac done warn() { printf '%s: WARNING: %s\n' "$prog" "$*" >&2; } die() { printf '%s: error: %s\n' "$prog" "$*" >&2; exit 2; } short() { local h=${1#sha256:}; printf '%s' "${h:0:12}"; } [[ -f $tags_file && -f $ids_file ]] || die "no snapshot (${tags_file##*/}, ${ids_file##*/} in $here); run tests/docker/snapshot.sh first" command -v docker >/dev/null 2>&1 || die "docker CLI not found" docker info >/dev/null 2>&1 || die "cannot talk to the Docker daemon" # ---- state ----------------------------------------------------------------- declare -A before_tag=() # repo:tag -> id (snapshot) declare -A before_id=() # id -> 1 (snapshot: every id, incl. untagged) declare -A tag_id=() # repo:tag -> id (current; simulated under --dry-run) declare -A tag_count=() # id -> number of repo:tag names on it declare -A id_present=() # id -> 1 declare -A used_running=() # id -> "name (status), ..." running/paused/restarting containers declare -A used_stopped=() # id -> "name (status), ..." created/exited/dead containers declare -A id_names=() # id -> "repo:tag ..." as found at start (for messages only) declare -a done_retag=() done_untag=() done_remove=() skipped=() failed=() # keys_sorted NAME: keys of associative array NAME, one per line, C-sorted. keys_sorted() { local -n _a=$1 ((${#_a[@]})) || return 0 printf '%s\n' "${!_a[@]}" | LC_ALL=C sort } # users_of ID: "running container(s): ...; stopped container(s): ..." or empty. users_of() { local s= [[ -z ${used_running[$1]:-} ]] || s="running container(s): ${used_running[$1]}" [[ -z ${used_stopped[$1]:-} ]] || s+="${s:+; }stopped container(s): ${used_stopped[$1]}" printf '%s' "$s" } # run CMD...: print "+ CMD"; unless --dry-run, run it, show its output indented # and leave a one-line error in $run_err on failure. run_err= run() { local out rc=0 local -a lines run_err= printf ' + %s\n' "$*" ((!dry_run)) || return 0 out=$("$@" 2>&1) || rc=$? if [[ -n $out ]]; then mapfile -t lines <<<"$out" printf ' %s\n' "${lines[@]}" fi ((rc == 0)) || run_err=${out//$'\n'/ } return "$rc" } skip() { printf ' - skip: %s\n' "$*" skipped+=("$*") } read_before() { local rt id while IFS=$'\t' read -r rt id || [[ -n $rt ]]; do [[ -n $rt && $rt != \#* ]] || continue if [[ ! $id =~ ^sha256:[0-9a-f]{64}$ ]]; then warn "ignoring malformed line in ${tags_file##*/}: $rt $id" continue fi before_id[$id]=1 [[ $rt == *''* ]] || before_tag[$rt]=$id done <"$tags_file" while read -r id || [[ -n $id ]]; do [[ -n $id && $id != \#* ]] || continue before_id[$id]=1 done <"$ids_file" } # read_after: (re)load the current image/tag/container state from the daemon. read_after() { local out rt id status name local -a cids=() tag_id=() tag_count=() id_present=() used_running=() used_stopped=() out=$(docker images --no-trunc --format '{{.Repository}}:{{.Tag}}\t{{.ID}}') || die "docker images failed" while IFS=$'\t' read -r rt id; do [[ -n $rt && $rt != *''* ]] || continue tag_id[$rt]=$id tag_count[$id]=$((${tag_count[$id]:-0} + 1)) done <<<"$out" out=$(docker images -a -q --no-trunc) || die "docker images -a failed" while read -r id; do [[ -z $id ]] || id_present[$id]=1 done <<<"$out" # Containers can vanish between ps and inspect (other --rm runs finishing), # so inspect errors for individual containers are ignored. out=$(docker ps -a -q --no-trunc) || die "docker ps failed" [[ -z $out ]] || mapfile -t cids <<<"$out" ((${#cids[@]})) || return 0 out=$(docker inspect --format '{{.Image}}{{"\t"}}{{.State.Status}}{{"\t"}}{{.Name}}' "${cids[@]}" 2>/dev/null) || true while IFS=$'\t' read -r id status name; do [[ -n $id ]] || continue name="${name#/} ($status)" case $status in running | paused | restarting) used_running[$id]+="${used_running[$id]:+, }$name" ;; *) used_stopped[$id]+="${used_stopped[$id]:+, }$name" ;; esac done <<<"$out" } # Model updates, applied after every successful (or, under --dry-run, planned) # command so later steps see the resulting state. model_tag() { # ID REF: REF now points at ID local id=$1 rt=$2 old=${tag_id[$2]:-} [[ -z $old ]] || tag_count[$old]=$((${tag_count[$old]:-1} - 1)) tag_id[$rt]=$id tag_count[$id]=$((${tag_count[$id]:-0} + 1)) id_present[$id]=1 } model_untag() { # REF: REF is gone; its image too if it was the last name and unused local rt=$1 id=${tag_id[$1]} n unset 'tag_id[$rt]' n=$((${tag_count[$id]:-1} - 1)) tag_count[$id]=$n if ((n <= 0)) && [[ -z ${before_id[$id]:-} && -z $(users_of "$id") ]]; then unset 'id_present[$id]' fi } model_remove() { # ID: image and all its names are gone local id=$1 rt unset 'id_present[$id]' tag_count[$id]=0 for rt in "${!tag_id[@]}"; do [[ ${tag_id[$rt]} != "$id" ]] || unset 'tag_id[$rt]' done } # ---- b. re-tag pre-existing names that were taken or vanished ---------------- step_retag() { local rt id cur from n=0 local -a keys mapfile -t keys < <(keys_sorted before_tag) for rt in "${keys[@]}"; do id=${before_tag[$rt]} cur=${tag_id[$rt]:-} [[ $cur != "$id" ]] || continue n=$((n + 1)) if [[ -z ${id_present[$id]:-} ]] && ! docker image inspect "$id" >/dev/null 2>&1; then warn "!!! pre-existing image $(short "$id") for $rt NO LONGER EXISTS: cannot restore $rt (re-pull it by digest or re-snapshot)" continue fi if run docker tag "$id" "$rt"; then from="" [[ -z $cur ]] || from=$(short "$cur") done_retag+=("$rt: $from -> $(short "$id")") model_tag "$id" "$rt" else failed+=("docker tag $(short "$id") $rt: $run_err") fi done ((n)) || echo " (nothing to do)" } # ---- c. untag names that did not exist before ------------------------------- step_untag() { local rt id users n=0 local -a keys mapfile -t keys < <(keys_sorted tag_id) for rt in "${keys[@]}"; do [[ -z ${before_tag[$rt]:-} ]] || continue n=$((n + 1)) id=${tag_id[$rt]} if ((${tag_count[$id]:-0} <= 1)); then # Last name on this image: `docker rmi` would delete the image as well. if [[ -n ${before_id[$id]:-} ]]; then skip "tag $rt: only name of pre-existing image $(short "$id"), kept so that image is not deleted" continue fi users=$(users_of "$id") if [[ -n $users ]]; then skip "tag $rt ($(short "$id")): image in use by $users" continue fi fi if run docker rmi --no-prune "$rt"; then model_untag "$rt" if [[ -n ${id_present[$id]:-} ]]; then done_untag+=("$rt ($(short "$id"))") else done_untag+=("$rt ($(short "$id"), last name: image deleted)") fi else failed+=("docker rmi $rt: $run_err") fi done ((n)) || echo " (nothing to do)" } # ---- d. delete image ids that did not exist before --------------------------- step_remove() { local id users label pass nskip=0 local -a keys todo=() retry=() local -A err=() mapfile -t keys < <(keys_sorted id_present) for id in "${keys[@]}"; do [[ -z ${before_id[$id]:-} ]] || continue users=$(users_of "$id") if [[ -n $users ]]; then skip "image $(short "$id")${id_names[$id]:+ (${id_names[$id]})}: in use by $users" nskip=$((nskip + 1)) continue fi todo+=("$id") done if ((${#todo[@]} == 0)); then ((nskip)) || echo " (nothing to do)" return 0 fi # With --no-prune a parent image can only go after its children: retry # failures for as long as each pass makes progress. for ((pass = 1; ${#todo[@]} > 0; pass++)); do retry=() for id in "${todo[@]}"; do label="$(short "$id")${id_names[$id]:+ ${id_names[$id]}}" if ((!dry_run)) && ! docker image inspect "$id" >/dev/null 2>&1; then done_remove+=("$label (already gone)") continue fi if run docker rmi -f --no-prune "$id"; then done_remove+=("$label") model_remove "$id" else retry+=("$id") err[$id]=$run_err fi done ((${#retry[@]} < ${#todo[@]})) || break todo=("${retry[@]}") done for id in "${retry[@]}"; do failed+=("docker rmi -f $(short "$id"): ${err[$id]}") done } # section TITLE ITEM...: print a summary section with its item count. section() { local title=$1 shift printf '%-44s %d\n' "$title" "$#" (($# == 0)) || printf ' %s\n' "$@" } # ---- main ------------------------------------------------------------------- read_before read_after while IFS= read -r rt; do [[ -z $rt ]] || id_names[${tag_id[$rt]}]+="${id_names[${tag_id[$rt]}]:+ }$rt" done < <(keys_sorted tag_id) mode="cleanup" ((!dry_run)) || mode="cleanup DRY RUN (no docker command is executed)" printf '== %s ==\n' "$mode" printf 'snapshot: %d tags, %d ids (taken %s)\n' "${#before_tag[@]}" "${#before_id[@]}" \ "$(date -r "$tags_file" '+%Y-%m-%d %H:%M:%S %Z')" printf 'now: %d tags, %d ids, %d image(s) used by containers\n' "${#tag_id[@]}" "${#id_present[@]}" \ "$(printf '%s\n' "${!used_running[@]}" "${!used_stopped[@]}" | sort -u | grep -c . || true)" echo "-- b. re-tag pre-existing tags that were taken or vanished" step_retag ((dry_run)) || read_after echo "-- c. untag tags that are new since the snapshot" step_untag ((dry_run)) || read_after echo "-- d. delete image ids that are new since the snapshot" step_remove ((dry_run)) || read_after # ---- e. summary --------------------------------------------------------------- declare -a bad=() left=() gone=() while IFS= read -r rt; do [[ -n $rt ]] || continue id=${before_tag[$rt]} cur=${tag_id[$rt]:-} [[ $cur != "$id" ]] || continue if [[ -z ${id_present[$id]:-} ]]; then bad+=("$rt: snapshot image $(short "$id") no longer exists") elif [[ -z $cur ]]; then bad+=("$rt: tag missing (should be $(short "$id"))") else bad+=("$rt: points at $(short "$cur"), should be $(short "$id")") fi done < <(keys_sorted before_tag) while IFS= read -r rt; do [[ -n $rt && -z ${before_tag[$rt]:-} ]] || continue users=$(users_of "${tag_id[$rt]}") left+=("tag $rt -> $(short "${tag_id[$rt]}")${users:+ [in use by $users]}") done < <(keys_sorted tag_id) while IFS= read -r id; do [[ -n $id && -z ${before_id[$id]:-} ]] || continue users=$(users_of "$id") left+=("image $(short "$id")${id_names[$id]:+ ${id_names[$id]}}${users:+ [in use by $users]}") done < <(keys_sorted id_present) while IFS= read -r id; do [[ -n $id && -z ${id_present[$id]:-} ]] || continue gone+=("$(short "$id")") done < <(keys_sorted before_id) echo if ((dry_run)); then echo "== summary (DRY RUN: nothing was changed; this is the predicted end state) ==" else echo "== summary ==" fi section "re-tagged (pre-existing tag restored):" "${done_retag[@]}" section "untagged (new tag removed):" "${done_untag[@]}" section "removed (new image deleted):" "${done_remove[@]}" section "skipped:" "${skipped[@]}" section "failed:" "${failed[@]}" section "left over (new since snapshot, still here):" "${left[@]}" section "pre-existing untagged ids gone (info only):" "${gone[@]}" section "PRE-EXISTING TAGS MISSING OR WRONG:" "${bad[@]}" if ((${#bad[@]})); then echo "result: FAILED - ${#bad[@]} pre-existing repo:tag(s) not restored" exit 1 fi echo "result: OK - all ${#before_tag[@]} pre-existing repo:tags point at their snapshot ids"