Files
ducoterra 0242ab5b5e pin the base image to a digest, with TTL refresh and audit labels
A tag is a moving pointer, and three things conspired to make the base
immutable-in-practice while mutable-in-theory: image_hash() hashed the
string "ubuntu:24.04" rather than what it resolves to, ensure_image()
returns early when the tag exists, and podman build's pull policy is
"missing" -- so even 'build --rebuild' rebuilt on the cached base. A
Canonical patch simply never arrived, and nothing said so.

Now the tag is resolved to a registry digest (skopeo, falling back to a
pull unless AISB_BASE_PULL_FALLBACK=0), recorded per-ref under
$AISB_CACHE_DIR/base/ at 0600, and used in FROM *and* in image_hash():
refreshing the base produces a new image instead of mutating one a
sandbox may be running. Freshness is a TTL (AISB_BASE_TTL_DAYS=7, 0 every
build, -1 never) plus an explicit 'update' / 'update --check'.

Two rules keep it usable rather than merely strict. A stale pin beats a
mutable tag: offline or with AISB_BASE_AUTO_UPDATE=0 the recorded digest
is still used, and only a build with no digest at all is allowed --
announced as UNPINNED, labelled that way, warned about by doctor. And
preview modes stay deterministic: --print-* never reach the network,
inspect is the surface that asks the machine now.

Both images carry aisb.base-digest / .node / .pi-package / .pi-version
labels plus /etc/aisb/manifest.txt of resolved apt versions, which answers
"which squid was enforcing this?" after the build log is gone. Tags are
named after the base instead of the hardcoded u24- prefix.

Bug found on the way: POL[base_digest]="$(base_pin ...)" silently produced
unpinned labels, because an assignment inside a command substitution is
discarded with the subshell. Helpers return text; callers assign. Also
fixed: the manifest step used jq on npm's dependency objects as if they
were strings, and a missing Node checksum used to skip verification
quietly -- it now warns.

311 assertions pass (unit + e2e), shellcheck clean.

LLM-Generated: true
2026-10-27 15:41:13 -04:00

183 lines
8.3 KiB
Bash

#!/usr/bin/env bash
# tests/unit/07_base.sh -- base image pinning: reproducibility, TTL, offline fallbacks.
#
# No registry traffic: the digest lookup binary is injectable (AISB_SKOPEO), so the
# whole refresh path is testable with a two-line stub.
source "$TESTS/lib.sh"
BASE="docker.io/library/ubuntu:24.04"
PIN1="sha256:$(printf '1%.0s' $(seq 64))"
PIN2="sha256:$(printf '2%.0s' $(seq 64))"
PIN3="sha256:$(printf '3%.0s' $(seq 64))"
PINX="sha256:$(printf '9%.0s' $(seq 64))"
# The stub stands in for skopeo and prints whatever digest is in this file, so a test
# can decide what "upstream" says -- including "unreachable", by deleting it.
FAKE="$WORK/fake-skopeo"
export FAKE_DIGEST_FILE="$WORK/digest"
cat > "$FAKE" <<'EOS'
#!/usr/bin/env bash
case "${1:-}" in
--version) echo "skopeo version test-stub" ;;
inspect) [[ -r "$FAKE_DIGEST_FILE" ]] || exit 1
cat "$FAKE_DIGEST_FILE" ;;
*) exit 1 ;;
esac
EOS
chmod +x "$FAKE"
export AISB_SKOPEO="$FAKE"
export AISB_BASE_AUTO_UPDATE=0 # nothing reaches the network by accident here
export AISB_BASE_TTL_DAYS=7
export AISB_BASE_PULL_FALLBACK=0 # never fall back to a real 'podman pull' in a unit test
state_of() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(printf '%s' "$1" | sha256sum | cut -c1-12)"; }
seed_pin() { # $1=ref $2=digest $3=age_days
local f; f="$(state_of "$1")"; mkdir -p "${f%/*}"
printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" \
"$(( $(date +%s) - (${3:-0} * 86400) ))" > "$f"
}
clear_pin() { rm -f "$(state_of "$1")"; }
says_pin() { printf '%s' "$OUT" | grep -qF -- "$1"; }
cf() { sb --print-containerfile "$@"; }
policy() { sb --print-policy --net none "$@"; }
t_name "an unpinned base builds from the tag and says so"
clear_pin "$BASE"
cf
assert_contains 'aisb.base-digest="unpinned"' "the image records that it had no digest"
if says_pin "FROM ${BASE}@"; then
_bad "FROM stays a plain tag when nothing is recorded"
else
_ok "FROM stays a plain tag when nothing is recorded"
fi
t_name "a recorded digest is what lands in FROM"
printf '%s\n' "$PIN1" > "$FAKE_DIGEST_FILE"
seed_pin "$BASE" "$PIN1" 0
cf
assert_contains "FROM ${BASE}@${PIN1}" "the Containerfile is pinned to the digest"
assert_contains "aisb.base-digest=\"$PIN1\"" "and labels the image with it"
assert_contains "aisb.node=" "the Node version is labelled too"
assert_contains "aisb.pi-version=" "and the harness version"
assert_contains "/etc/aisb/manifest.txt" "the package manifest is written into the image"
t_name "preview modes never touch the registry"
printf '%s\n' "$PINX" > "$FAKE_DIGEST_FILE" # upstream would answer something else
export AISB_BASE_AUTO_UPDATE=1
cf
assert_contains "$PIN1" "--print-containerfile used the recorded digest, not the registry"
assert_absent "$PINX" "nothing from the stub leaked into the Containerfile"
policy
assert_contains "base_digest=$PIN1" "--print-policy likewise"
export AISB_BASE_AUTO_UPDATE=0
t_name "the pin is part of the image identity"
# refs are read at top level: sb() sets OUT, and a command substitution would throw
# that assignment away.
refs() { printf '%s %s' "$(printf '%s' "$OUT" | grep '^image=' | cut -d= -f2)" \
"$(printf '%s' "$OUT" | grep '^gate_image=' | cut -d= -f2)"; }
clear_pin "$BASE"; policy; read -r UNPINNED_IMG UNPINNED_GATE < <(refs)
seed_pin "$BASE" "$PIN1" 0; policy; read -r ONE ONE_GATE < <(refs)
seed_pin "$BASE" "$PIN2" 0; policy; read -r TWO TWO_GATE < <(refs)
[[ "$ONE" != "$UNPINNED_IMG" ]] && _ok "pinning changes the tag" || _bad "pinning changes the tag"
[[ -n "$UNPINNED_GATE" && "$UNPINNED_GATE" != "$ONE_GATE" ]] \
&& _ok "the gate image tag moves with the pin too" || _bad "the gate image tag moves with the pin too"
[[ "$ONE" != "$TWO" ]] && _ok "a new base digest means a new image, not a mutated one" || _bad "a new base digest means a new image, not a mutated one"
[[ "$ONE_GATE" != "$TWO_GATE" ]] && _ok "the gate image follows the same base" || _bad "the gate image follows the same base"
assert_eq "yes" "$([[ "$TWO" == localhost/ai-sandbox:ubuntu-* ]] && echo yes || echo no)" "the tag is named after the base, not hardcoded"
policy --base-image docker.io/library/debian:12
assert_contains "image=localhost/ai-sandbox:debian-" "a different base gets an honest tag"
t_name "a base the user pinned by digest is used verbatim"
cf --base-image "alpine@${PIN3}"
assert_contains "FROM alpine@${PIN3}" "FROM is exactly what was asked for"
policy --base-image "alpine@${PIN3}"
assert_contains "base_digest=$PIN3" "and reported as the pin"
if [[ -e "$(state_of "alpine@${PIN3}")" ]]; then
_bad "no state is written for a ref that is already pinned"
else
_ok "no state is written for a ref that is already pinned"
fi
# --print-policy is the deterministic preview surface and never resolves; `inspect`
# is the "ask the machine now" surface, so TTL behaviour is asserted through it.
t_name "the TTL decides when the digest is re-resolved"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
clear_pin "$BASE"; seed_pin "$BASE" "$PIN1" 30 # 30 days old
policy
assert_contains "$PIN1" "an old pin is kept when auto-update is off"
export AISB_BASE_AUTO_UPDATE=1
AISB_BASE_TTL_DAYS=-1 sb inspect --net none
assert_contains "$PIN1" "ttl -1 pins until told otherwise"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "$PIN3" "past the TTL the upstream digest replaces the stale pin"
grep -qF "$PIN3" "$(state_of "$BASE")" && _ok "and the new digest is recorded" || _bad "and the new digest is recorded"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "$PIN3" "inside the new TTL nothing is re-resolved"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE" # (unchanged, for clarity of intent)
AISB_BASE_TTL_DAYS=7 policy
assert_contains "$PIN3" "the preview surface still reports the recorded pin"
clear_pin "$BASE"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "base_digest=$PIN3" "a first run resolves and records"
t_name "an unreachable registry falls back to the recorded digest"
seed_pin "$BASE" "$PIN1" 99
rm -f "$FAKE_DIGEST_FILE" # the stub now fails
export AISB_BASE_TTL_DAYS=0
sb inspect --net none
assert_contains "base_digest=$PIN1" "the stale pin is preferred over a mutable tag"
cf
assert_contains "FROM ${BASE}@${PIN1}" "and the build stays pinned"
t_name "nothing recorded and no registry: unpinned, with a warning"
clear_pin "$BASE"
sb inspect --net none
assert_contains "UNPINNED" "the run warns that the build is not reproducible"
cf
assert_contains 'aisb.base-digest="unpinned"' "and the image carries that fact"
t_name "update --check reports without rebuilding"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
seed_pin "$BASE" "$PIN1" 0
sb update --check
assert_rc 0 "a check succeeds"
assert_contains "base=$BASE" "it names the base"
assert_contains "recorded=$PIN1" "it shows the recorded digest"
assert_contains "upstream=$PIN3" "it shows the upstream digest"
assert_contains "update is available" "and says an update exists"
sb update --check --no-gate 2>/dev/null
printf '%s' "$PIN3" > "$FAKE_DIGEST_FILE"; seed_pin "$BASE" "$PIN3" 0
sb update --check
assert_contains "up to date" "and says nothing to do when the pin already matches"
rm -f "$FAKE_DIGEST_FILE"
sb update --check
assert_contains "could not reach the registry" "and says so when the registry is unreachable"
assert_rc 0 "an unreachable registry is reported, not fatal"
t_name "a bad ttl in config is refused, not obeyed blindly"
# the environment outranks config by design, so drop it to exercise the config path
unset AISB_BASE_TTL_DAYS
mkdir -p .ai-sandbox
printf 'base_ttl_days = soon\n' > .ai-sandbox/config
policy
assert_contains "must be an integer" "it says the value is not usable"
assert_contains "$PIN3" "and it carries on with the default"
rm -rf .ai-sandbox
export AISB_BASE_TTL_DAYS=7
t_name "the policy output explains the pin"
export AISB_BASE_AUTO_UPDATE=0 AISB_BASE_TTL_DAYS=7
seed_pin "$BASE" "$PIN1" 2
policy
assert_contains "base=$BASE" "the policy names the base tag"
assert_contains "base_digest=$PIN1" "the pin in force"
assert_contains "base_age_days=2" "and how old the pin is"
t_name "doctor's warning when a built image predates the pin"
# doctor itself starts a container, so check the logic it reports on, not the run:
# an image labelled with a different digest than the pin is the silent-staleness case.
seed_pin "$BASE" "$PIN2" 0
cf
assert_contains "aisb.base-digest=\"$PIN2\"" "a rebuild would be labelled with the new pin"