diff --git a/scripts/image.sh b/scripts/image.sh new file mode 100755 index 0000000..a0aaaa7 --- /dev/null +++ b/scripts/image.sh @@ -0,0 +1,654 @@ +#!/usr/bin/env bash +# Which locally built image is in play. +# +# ./scripts/image.sh list every arena image on this machine +# ./scripts/image.sh resolve the tag versions.env names +# ./scripts/image.sh resolve newest the most recent build +# ./scripts/image.sh resolve c836f26 the build made from that commit +# ./scripts/image.sh verify newest resolve it, and say what it is +# ./scripts/image.sh diff 4e5403f c836f26 what changed between two builds +# +# A machine that has built this repo a few times has a dozen tags on it, spanning +# more than one Suramadu version, and the tag string is the only thing telling +# them apart. Reading a tag is guesswork; this script answers instead. +# +# `resolve` prints one reference on stdout and nothing else, so anything that +# needs an image can ask here rather than composing IMAGE_NAME and IMAGE_TAG for +# itself: +# +# IMAGE="$(scripts/image.sh resolve "$SELECTOR")" +# +# Selectors: +# +# (nothing), current the tag versions.env names for this commit +# newest the most recently built image +# an exact tag, as `list` prints it +# a git sha, short or long; matches the sha in the tag +# : a full reference, taken as given +# +# Every command is read-only. Nothing here builds, pushes or removes an image - +# what to delete is your call, so `list` reports what is on disk and stops there. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../versions.env +. "$ROOT/versions.env" + +say() { printf 'image: %s\n' "$*" >&2; } +die() { say "$*"; exit 1; } + +usage() { + cat <<'USAGE' +image.sh - which locally built image is in play + + image.sh list every arena image on this machine + image.sh resolve [] print one reference, and nothing else + image.sh verify [] resolve it, and say what it is + image.sh diff [--deep] what changed between two builds + +selectors + + (nothing), current the tag versions.env names for this commit + newest the most recently built image + an exact tag, as `list` prints it + a git sha, short or long; matches the sha in the tag + : a full reference, taken as given + +--deep runs one throwaway container per image to read versions.env and the +container scripts out of them. Everything else is `docker image inspect`. +USAGE +} + +# One temp directory per run, cleaned up on the way out. A RETURN trap per +# function would be tidier to read but traps are not scoped to a function, so +# the second one to be set wins and the first directory is never removed. +# +# It sets $TMP rather than printing it: a command substitution runs in a +# subshell, so the EXIT trap would fire there and delete the directory before +# the caller had written anything into it. +TMP="" +tmpdir() { + if [ -z "$TMP" ]; then + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + fi +} + +# --- facts a tag carries ----------------------------------------------------- + +# versions.env builds the tag as mm-sur-, where is +# the branch, a dash, and a 12-character sha - with the branch dropped on a +# detached HEAD and "-dirty" appended when the tree was not clean. +# +# So the commit is the LAST dash-separated field, never the third: branch names +# contain dashes, and this repo's branches are all `claude/some-long-name`. Tags +# from before the ref was part of the name (mm0.51.0-ws26.4.5) end in a version +# rather than hex, and get no commit at all, which is the right answer for them. +tag_commit() { + local tag="${1%-dirty}" last + last="${tag##*-}" + case "$last" in + "" | *[!0-9a-f]*) return 0 ;; + esac + # Seven is git's own floor for an abbreviated sha. Below it, a short lowercase + # field like "abc" is far more likely to be part of a branch name. + [ "${#last}" -ge 7 ] || return 0 + printf '%s' "$last" +} + +# The commit an image came from: the tag first, then the revision label the build +# baked in. They agree on everything built since the tag started carrying a ref; +# the label is what answers for the tags from before that, which is most of what +# is on a machine that has been building this repo for a week. +image_commit() { + local sha + sha="$(tag_commit "$1")" + [ -n "$sha" ] || sha="$(tag_commit "${2:-}")" + printf '%s' "$sha" +} + +tag_is_dirty() { + case "$1" in *-dirty) return 0 ;; esac + return 1 +} + +# A tag naming a commit nobody can `git show` is a build nobody can reproduce. +# scripts/deploy.sh refuses to ship one; here it is only reported, because an +# unreachable commit on a local image is normal - it is usually a rebased branch. +commit_in_repo() { + [ -n "$1" ] || return 1 + git -C "$ROOT" cat-file -e "${1}^{commit}" 2>/dev/null +} + +commit_subject() { + git -C "$ROOT" log -1 --format=%s "$1" 2>/dev/null || true +} + +# A selector and a tag's commit match when either is a prefix of the other: the +# tag carries 12 characters, `git log --oneline` prints 7, and people paste all +# 40. Four is the shortest prefix worth trusting to mean one commit. +sha_matches() { + local sel="$1" have="$2" + [ -n "$have" ] || return 1 + [ "${#sel}" -ge 4 ] || return 1 + case "$have" in "$sel"*) return 0 ;; esac + case "$sel" in "$have"*) return 0 ;; esac + return 1 +} + +# --- formatting -------------------------------------------------------------- + +# Integer arithmetic only, so this needs nothing beyond bash. KiB is here for +# `diff`: two builds off the same base differ by a metadata layer, and "0.0 MiB +# larger" is not an answer. +human_bytes() { + local b="$1" unit scaled + if [ "$b" -lt 1048576 ]; then + scaled=$(( (b * 10 + 512) / 1024 )); unit=KiB + elif [ "$b" -lt 1073741824 ]; then + scaled=$(( (b * 10 + 524288) / 1048576 )); unit=MiB + else + scaled=$(( (b * 10 + 536870912) / 1073741824 )); unit=GiB + fi + printf '%d.%d %s' $(( scaled / 10 )) $(( scaled % 10 )) "$unit" +} + +# 2026-08-07T17:44:54.895042045-04:00 -> 2026-08-07 17:44 +short_time() { printf '%s %s' "${1:0:10}" "${1:11:5}"; } + +short_id() { local id="${1#sha256:}"; printf '%s' "${id:0:12}"; } + +# --- reading the daemon ------------------------------------------------------ + +need_docker() { + command -v docker >/dev/null || die "docker is not installed" +} + +# id|size|created|revision, for one reference. The revision label is what +# build.sh baked in from BUILD_REF, so it is the image's own copy of the commit - +# worth having next to the one parsed out of the tag. +inspect_fields() { + docker image inspect --format \ + '{{.Id}}|{{.Size}}|{{.Created}}|{{with index .Config.Labels "org.opencontainers.image.revision"}}{{.}}{{end}}' \ + "$1" +} + +image_exists() { docker image inspect "$1" >/dev/null 2>&1; } + +# scripts/run.sh has a one-line version of this check. The difference is what it +# can say afterwards: the usual cause of a miss is that versions.env names a tag +# for THIS commit and no build has produced it yet, which reads as "not built" +# when what changed was the commit. +require_image() { + local ref="$1" sel="${2:-}" + if image_exists "$ref"; then return 0; fi + say "no such image on this machine: $ref" + if [ -z "$sel" ] || [ "$sel" = current ]; then + say "that is the tag versions.env names for this commit - build it:" + say " ./scripts/build.sh" + fi + say "./scripts/image.sh list shows what is on disk" + exit 1 +} + +# Every tag of IMAGE_NAME, grouped by image id, newest first. +# +# Grouping matters for the totals: build.sh tags each build `latest` as well as +# its immutable tag, so two rows share one set of layers. A row per tag would +# imply disk usage that is not there. +# +# The sort is ours rather than `docker image ls`'s, because timestamps here are +# not unique - three builds of this repo carry the same Created value down to the +# nanosecond, and docker orders those ties differently from one run to the next, +# so the same command printed a different "newest" each time. Sorting on the +# second and then on the tag makes the output the same twice in a row; which of +# a tied set is really the newest build is a question for git, and `newest` +# answers it below. +IMG_LOADED=0 +G_TAG=() # the tag that best names the group +G_ALIAS=() # its other tags, space separated +G_ID=() +G_SIZE=() +G_CREATED=() +G_REV=() + +img_load() { + if [ "$IMG_LOADED" = 1 ]; then return 0; fi + IMG_LOADED=1 + need_docker + + local tags=() + mapfile -t tags < <(docker image ls "$IMAGE_NAME" --format '{{.Tag}}' | grep -vx '' || true) + if [ "${#tags[@]}" -eq 0 ]; then return 0; fi + + local refs=() t + for t in "${tags[@]}"; do refs+=("$IMAGE_NAME:$t"); done + + # One inspect for the whole list. Its output is one line per argument, in + # order, which is the only thing tying a line back to its tag - so if the + # counts disagree, something removed an image while this was running and the + # rows would be attached to the wrong tags. + local rows=() + mapfile -t rows < <(docker image inspect --format \ + '{{.Id}}|{{.Size}}|{{.Created}}|{{with index .Config.Labels "org.opencontainers.image.revision"}}{{.}}{{end}}' \ + "${refs[@]}") + if [ "${#rows[@]}" -ne "${#tags[@]}" ]; then + die "the image list changed while it was being read; run this again" + fi + + # Sort on the whole second only. Docker prints RFC 3339 with the trailing + # zeros trimmed, so the fractional part is not a fixed width and comparing it + # as text is not comparing time. + local order=() when + mapfile -t order < <( + for i in "${!tags[@]}"; do + when="${rows[$i]#*|}"; when="${when#*|}" + printf '%s\t%s\t%s\n' "${when:0:19}" "${tags[$i]}" "$i" + done | sort -t$'\t' -k1,1r -k2,2 + ) + + local row i id size created rev g found + for row in "${order[@]}"; do + i="${row##*$'\t'}" + IFS='|' read -r id size created rev <<<"${rows[$i]}" + found=-1 + for g in "${!G_ID[@]}"; do + if [ "${G_ID[$g]}" = "$id" ]; then found="$g"; break; fi + done + if [ "$found" -lt 0 ]; then + G_TAG+=("${tags[$i]}"); G_ALIAS+=(""); G_ID+=("$id") + G_SIZE+=("$size"); G_CREATED+=("$created"); G_REV+=("$rev") + continue + fi + # `latest` moves with every build and cannot say which one it is, so a tag + # that carries a commit takes the row and the moving one becomes the alias. + if [ -z "$(tag_commit "${G_TAG[$found]}")" ] && [ -n "$(tag_commit "${tags[$i]}")" ]; then + G_ALIAS[$found]="${G_ALIAS[$found]:+${G_ALIAS[$found]} }${G_TAG[$found]}" + G_TAG[$found]="${tags[$i]}" + else + G_ALIAS[$found]="${G_ALIAS[$found]:+${G_ALIAS[$found]} }${tags[$i]}" + fi + done +} + +# The newest build, as a group index. +# +# Timestamps tie, so the first row is only a candidate. Among everything built in +# the same second, the newest build is the one whose commit has all the others +# behind it in git - which is a question the timestamps cannot answer and git +# always can. If they are not on one line of history there is no newest, and +# saying so beats picking whichever the daemon happened to list first. +newest_group() { + img_load + [ "${#G_TAG[@]}" -gt 0 ] || die "no $IMAGE_NAME image on this machine; run ./scripts/build.sh" + + local tied=() g + for g in "${!G_TAG[@]}"; do + if [ "${G_CREATED[$g]:0:19}" = "${G_CREATED[0]:0:19}" ]; then tied+=("$g"); fi + done + if [ "${#tied[@]}" -eq 1 ]; then printf '%s' "${tied[0]}"; return 0; fi + + # How many of the others each candidate has behind it. The tip has all of them. + local best=-1 bestscore=-1 score other ca cb ties=0 + for g in "${tied[@]}"; do + ca="$(image_commit "${G_TAG[$g]}" "${G_REV[$g]}")" + score=0 + for other in "${tied[@]}"; do + [ "$other" != "$g" ] || continue + cb="$(image_commit "${G_TAG[$other]}" "${G_REV[$other]}")" + if [ -n "$ca" ] && [ -n "$cb" ] \ + && git -C "$ROOT" merge-base --is-ancestor "$cb" "$ca" 2>/dev/null; then + score=$(( score + 1 )) + fi + done + if [ "$score" -gt "$bestscore" ]; then best="$g"; bestscore="$score"; ties=0 + elif [ "$score" -eq "$bestscore" ]; then ties=$(( ties + 1 )) + fi + done + + if [ "$ties" -gt 0 ]; then + say "${#tied[@]} images were built in the same second and git cannot order them:" + for g in "${tied[@]}"; do say " ${G_TAG[$g]}"; done + die "name one of them instead of 'newest'" + fi + printf '%s' "$best" +} + +# Index of the group holding a tag, or nothing. +group_of_tag() { + local want="$1" g t + for g in "${!G_TAG[@]}"; do + if [ "${G_TAG[$g]}" = "$want" ]; then printf '%s' "$g"; return 0; fi + for t in ${G_ALIAS[$g]}; do + if [ "$t" = "$want" ]; then printf '%s' "$g"; return 0; fi + done + done +} + +# --- resolve ----------------------------------------------------------------- + +resolve_selector() { + local sel="${1:-}" g hits=() + case "$sel" in + "" | current) + printf '%s:%s' "$IMAGE_NAME" "$IMAGE_TAG" + return 0 + ;; + *:*) + # Already a full reference. Nothing to derive, and second-guessing a + # repository someone typed in full is not this script's business. + printf '%s' "$sel" + return 0 + ;; + newest) + # Loaded here rather than only inside newest_group: that call is a command + # substitution, and a subshell cannot hand the group arrays back. + img_load + g="$(newest_group)" + printf '%s:%s' "$IMAGE_NAME" "${G_TAG[$g]}" + return 0 + ;; + esac + + img_load + + # An exact tag wins over a sha. It is unambiguous, and it is the string `list` + # printed, so what you copied is what you get. + g="$(group_of_tag "$sel")" + if [ -n "$g" ]; then + printf '%s:%s' "$IMAGE_NAME" "$sel" + return 0 + fi + + for g in "${!G_TAG[@]}"; do + if sha_matches "$sel" "$(image_commit "${G_TAG[$g]}" "${G_REV[$g]}")"; then hits+=("$g"); fi + done + + case "${#hits[@]}" in + 0) die "no tag and no build matches '$sel'; ./scripts/image.sh list shows what is on disk" ;; + 1) printf '%s:%s' "$IMAGE_NAME" "${G_TAG[${hits[0]}]}"; return 0 ;; + esac + + say "'$sel' matches more than one build:" + for g in "${hits[@]}"; do say " ${G_TAG[$g]}"; done + die "name one of them" +} + +# --- commands ---------------------------------------------------------------- + +cmd_resolve() { + [ "$#" -le 1 ] || die "usage: image.sh resolve []" + local ref + ref="$(resolve_selector "${1:-}")" + require_image "$ref" "${1:-}" + printf '%s\n' "$ref" +} + +cmd_verify() { + [ "$#" -le 1 ] || die "usage: image.sh verify []" + local ref sel="${1:-}" + ref="$(resolve_selector "$sel")" + require_image "$ref" "$sel" + + local id size created rev sha subject g + IFS='|' read -r id size created rev < <(inspect_fields "$ref") + sha="$(image_commit "${ref##*:}" "$rev")" + + printf '%s\n' "$ref" + printf ' id %s\n' "$(short_id "$id")" + printf ' size %s (docker image inspect .Size)\n' "$(human_bytes "$size")" + printf ' built %s\n' "$(short_time "$created")" + printf ' ref %s (baked into the image as a label)\n' "${rev:--}" + + if [ -z "$sha" ]; then + printf ' commit none in the tag, and no revision label either\n' + elif commit_in_repo "$sha"; then + subject="$(commit_subject "$sha")" + printf ' commit %s %s\n' "$sha" "$subject" + else + printf ' commit %s NOT in this repo - nothing can rebuild this image\n' "$sha" + fi + if tag_is_dirty "${ref##*:}"; then + printf ' built from an uncommitted tree; the commit above is not what it contains\n' + fi + + img_load + g="$(group_of_tag "${ref##*:}")" + if [ -n "$g" ] && [ -n "${G_ALIAS[$g]}" ]; then + printf ' also %s\n' "${G_ALIAS[$g]}" + fi +} + +cmd_list() { + [ "$#" -eq 0 ] || die "usage: image.sh list" + img_load + if [ "${#G_TAG[@]}" -eq 0 ]; then + say "no $IMAGE_NAME image on this machine; run ./scripts/build.sh" + return 0 + fi + + local g w=3 t total=0 orphans=0 mark sha state + for t in "${G_TAG[@]}"; do + if [ "${#t}" -gt "$w" ]; then w="${#t}"; fi + done + + # By group rather than by tag: the tag versions.env names can be an alias of + # the row, and the row is still the image it means. + local current; current="$(group_of_tag "$IMAGE_TAG")" + + printf '%-2s %-*s %-12s %10s %-16s %-12s %s\n' \ + '' "$w" TAG ID SIZE BUILT COMMIT 'IN REPO' + for g in "${!G_TAG[@]}"; do + sha="$(image_commit "${G_TAG[$g]}" "${G_REV[$g]}")" + if [ -z "$sha" ]; then + state='-' + elif commit_in_repo "$sha"; then + state=yes + else + state=no + orphans=$(( orphans + 1 )) + fi + mark=' ' + if [ "$g" = "$current" ]; then mark='*'; fi + printf '%-2s %-*s %-12s %10s %-16s %-12s %s\n' \ + "$mark" "$w" "${G_TAG[$g]}" "$(short_id "${G_ID[$g]}")" \ + "$(human_bytes "${G_SIZE[$g]}")" "$(short_time "${G_CREATED[$g]}")" \ + "${sha:--}" "$state" + if [ -n "${G_ALIAS[$g]}" ]; then + printf ' also tagged: %s\n' "${G_ALIAS[$g]}" + fi + total=$(( total + G_SIZE[g] )) + done + + printf '\n%d image(s), %s total, counted once per image id\n' \ + "${#G_TAG[@]}" "$(human_bytes "$total")" + if [ -n "$current" ]; then + printf '* %s is what versions.env names for this commit\n' "$IMAGE_TAG" + else + printf '* nothing here is %s, which is what versions.env names for this commit\n' "$IMAGE_TAG" + fi + if [ "$orphans" -gt 0 ]; then + printf '%d image(s) name a commit this repo does not have; nothing can rebuild them\n' "$orphans" + fi + report_dangling +} + +# Untagged images are not necessarily arena's - a rebuild of anything on this +# machine leaves one behind. Reported rather than removed: which of them is worth +# keeping is not a decision a script gets to make. +report_dangling() { + local ids=() sizes=() n s total=0 + mapfile -t ids < <(docker image ls -f dangling=true --format '{{.ID}}' || true) + n="${#ids[@]}" + if [ "$n" -eq 0 ]; then return 0; fi + mapfile -t sizes < <(docker image inspect --format '{{.Size}}' "${ids[@]}" 2>/dev/null || true) + for s in "${sizes[@]}"; do total=$(( total + s )); done + printf '%d untagged image(s) hold %s, from any project on this machine:\n' \ + "$n" "$(human_bytes "$total")" + printf ' docker image ls -f dangling=true\n' +} + +# --- diff -------------------------------------------------------------------- + +# The same trick scripts/run.sh uses to read a scenario out of an image: one +# throwaway container, no entrypoint, one command that reads files and exits. +# versions.env is copied into the image, so this is the build's own record of +# what it pinned rather than an inference from the tag. +# +# Capped on purpose. This cats a file and hashes a handful of scripts; if it ever +# needs more than a core and half a gigabyte, something is wrong with it. +DEEP_READ=' +echo "=== /opt/arena/versions.env" +cat /opt/arena/versions.env 2>/dev/null || echo "(this image has no versions.env)" +echo "=== container scripts (sha256)" +find /opt/arena/container -name "*.sh" 2>/dev/null | sort \ + | xargs -r sha256sum 2>/dev/null | sed "s|/opt/arena/||" \ + || echo "(no container scripts)" +' + +deep_read() { + docker run --rm --cpus 1 --memory 512m --entrypoint /bin/sh "$1" -c "$DEEP_READ" +} + +cmd_diff() { + local deep=0 + if [ "${1:-}" = --deep ]; then deep=1; shift; fi + [ "$#" -eq 2 ] || die "usage: image.sh diff [--deep] " + + local a b + a="$(resolve_selector "$1")"; require_image "$a" "$1" + b="$(resolve_selector "$2")"; require_image "$b" "$2" + + local aid asize acreated arev bid bsize bcreated brev + IFS='|' read -r aid asize acreated arev < <(inspect_fields "$a") + IFS='|' read -r bid bsize bcreated brev < <(inspect_fields "$b") + + printf 'a %s\n %s %s %s\n' "$a" "$(short_id "$aid")" \ + "$(human_bytes "$asize")" "$(short_time "$acreated")" + printf 'b %s\n %s %s %s\n\n' "$b" "$(short_id "$bid")" \ + "$(human_bytes "$bsize")" "$(short_time "$bcreated")" + + if [ "$aid" = "$bid" ]; then + printf 'Both selectors name the same image.\n' + return 0 + fi + + local delta=$(( bsize - asize )) word=larger + if [ "$delta" -lt 0 ]; then delta=$(( -delta )); word=smaller; fi + if [ "$delta" -eq 0 ]; then + printf 'size the same, %s\n' "$(human_bytes "$asize")" + else + printf 'size b is %s %s (%d bytes)\n' "$(human_bytes "$delta")" "$word" "$delta" + fi + + diff_labels "$a" "$b" + diff_commits "$(image_commit "${a##*:}" "$arev")" "$(image_commit "${b##*:}" "$brev")" + diff_layers "$a" "$b" + + if [ "$deep" = 1 ]; then diff_deep "$a" "$b"; fi +} + +# Go's template iterates a map in key order, so both sides come out sorted and a +# plain line comparison is enough. The key names moved when Webswing Lite became +# Suramadu, which is exactly the kind of difference a fixed list of keys hides. +diff_labels() { + tmpdir; local tmp="$TMP" + local fmt='{{range $k, $v := .Config.Labels}}{{$k}}={{$v}}{{"\n"}}{{end}}' + docker image inspect --format "$fmt" "$1" >"$tmp/labels-a" + docker image inspect --format "$fmt" "$2" >"$tmp/labels-b" + if diff -q "$tmp/labels-a" "$tmp/labels-b" >/dev/null; then + printf 'labels identical\n' + return 0 + fi + printf 'labels\n' + diff --unchanged-line-format= --old-line-format=' - %L' --new-line-format=' + %L' \ + "$tmp/labels-a" "$tmp/labels-b" || true +} + +# The revision label is the build's own copy of BUILD_REF, so the range below is +# between the commits the images were actually built from. +diff_commits() { + local ca="$1" cb="$2" n + if [ -z "$ca" ] || [ -z "$cb" ]; then + printf 'commits at least one image does not name a commit\n' + return 0 + fi + if ! commit_in_repo "$ca" || ! commit_in_repo "$cb"; then + printf 'commits %s -> %s, and this repo does not have both\n' "$ca" "$cb" + return 0 + fi + n="$(git -C "$ROOT" rev-list --count "$ca..$cb" 2>/dev/null || echo 0)" + printf 'commits %s -> %s, %s commit(s) forward\n' "$ca" "$cb" "$n" + git -C "$ROOT" log --oneline --max-count=20 "$ca..$cb" 2>/dev/null | sed 's/^/ /' || true +} + +# Two builds off the same base share every layer up to the first thing that +# changed, and where that is says whether the diff is a rebuild of the world or a +# metadata bump. .RootFS.Layers is the cheap answer; docker history names the +# step. +diff_layers() { + tmpdir; local tmp="$TMP" + local fmt='{{range .RootFS.Layers}}{{.}}{{"\n"}}{{end}}' + docker image inspect --format "$fmt" "$1" >"$tmp/layers-a" + docker image inspect --format "$fmt" "$2" >"$tmp/layers-b" + + local la=() lb=() common=0 i + mapfile -t la <"$tmp/layers-a" + mapfile -t lb <"$tmp/layers-b" + for i in "${!la[@]}"; do + if [ "$i" -ge "${#lb[@]}" ] || [ "${la[$i]}" != "${lb[$i]}" ]; then break; fi + common=$(( common + 1 )) + done + printf 'layers %d shared, then %d in a and %d in b\n' \ + "$common" "$(( ${#la[@]} - common ))" "$(( ${#lb[@]} - common ))" + + # docker history prints newest first; reversed, step 1 is the base image and + # the numbering lines up with reading the Dockerfile top to bottom. + local ha=() hb=() + mapfile -t ha < <(docker history --no-trunc --format '{{.CreatedBy}}' "$1" | tac) + mapfile -t hb < <(docker history --no-trunc --format '{{.CreatedBy}}' "$2" | tac) + for i in "${!ha[@]}"; do + if [ "$i" -ge "${#hb[@]}" ] || [ "${ha[$i]}" != "${hb[$i]}" ]; then + printf 'history first difference at step %d of %d\n' "$(( i + 1 ))" "${#ha[@]}" + printf ' a %.100s\n' "${ha[$i]}" + if [ "$i" -lt "${#hb[@]}" ]; then printf ' b %.100s\n' "${hb[$i]}"; fi + return 0 + fi + done + printf 'history identical for all %d step(s)\n' "${#ha[@]}" +} + +diff_deep() { + tmpdir; local tmp="$TMP" + printf '\ninside the images\n' + # One at a time, and each exits as soon as it has read what it came for. + deep_read "$1" >"$tmp/deep-a" + deep_read "$2" >"$tmp/deep-b" + if diff -q "$tmp/deep-a" "$tmp/deep-b" >/dev/null; then + printf ' versions.env and the container scripts are identical\n' + return 0 + fi + diff -u --label a --label b "$tmp/deep-a" "$tmp/deep-b" | sed 's/^/ /' || true +} + +# --- entry ------------------------------------------------------------------- + +main() { + local cmd=list + if [ "$#" -gt 0 ]; then cmd="$1"; shift; fi + case "$cmd" in + list) cmd_list "$@" ;; + resolve) cmd_resolve "$@" ;; + verify) cmd_verify "$@" ;; + diff) cmd_diff "$@" ;; + -h | --help | help) usage ;; + *) die "unknown command '$cmd'; there is list, resolve, verify and diff" ;; + esac +} + +# Sourcing this file defines the helpers and runs nothing, which is how +# tests/shell/test-image.sh exercises the parsing where there is no docker +# daemon - the image's test stage, for one. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi