#!/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"