|
|
|
@@ -0,0 +1,182 @@
|
|
|
|
|
#!/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"
|