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
This commit is contained in:
+283
-17
@@ -39,6 +39,7 @@ AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
|
||||
# environment variables stay unset unless the user really set them.
|
||||
# AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS
|
||||
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
|
||||
# AISB_BASE_TTL_DAYS AISB_BASE_AUTO_UPDATE AISB_BASE_PULL_FALLBACK AISB_SKOPEO
|
||||
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
|
||||
AISB_UID="${AISB_UID:-1000}"
|
||||
AISB_GID="${AISB_GID:-1000}"
|
||||
@@ -135,8 +136,9 @@ $PROG $VERSION -- rootless podman sandbox for autonomous agentic development.
|
||||
|
||||
USAGE
|
||||
$PROG [POLICY ...] [--] [COMMAND ...]
|
||||
$PROG build [--rebuild] | doctor [--deep] | gate <up|down|status|logs> | ps
|
||||
$PROG clean [--all] [--volumes] | inspect | version
|
||||
$PROG build [--rebuild] [--refresh-base] [--gate]
|
||||
$PROG doctor [--deep] | gate <up|down|status|logs> | ps | inspect | version
|
||||
$PROG clean [--all] [--volumes[=GLOB]] | update [--check]
|
||||
|
||||
Sandbox flag parsing stops at the first positional argument or unknown flag.
|
||||
Those arguments become arguments *to the harness*, which is what makes an alias
|
||||
@@ -184,7 +186,9 @@ ENVIRONMENT
|
||||
through the command line, and never into the logs.
|
||||
|
||||
IMAGE
|
||||
--base-image REF Default docker.io/library/ubuntu:24.04
|
||||
--base-image REF Default docker.io/library/ubuntu:24.04. Pinned to the
|
||||
registry digest at build time; REF may itself already be
|
||||
a digest (name@sha256:...) to take that decision away.
|
||||
--install PKG Extra apt package in the image. Repeatable.
|
||||
Each distinct set gets its own cached image tag.
|
||||
--harness NAME Default in-container command: pi | codex | claude | none.
|
||||
@@ -211,7 +215,10 @@ ESCAPE HATCHES (they weaken the sandbox, which is why they are named this way)
|
||||
--strict-args Unknown flags are an error instead of a pass-through.
|
||||
|
||||
SUBCOMMANDS
|
||||
build Build or reuse the sandbox image (--rebuild, --gate for the gate image).
|
||||
build Build or reuse the sandbox image (--rebuild, --gate for the gate
|
||||
image, --refresh-base to re-resolve the base digest now).
|
||||
update Re-resolve the base digest and rebuild what that implies. The old
|
||||
image stays put as a rollback. --check only reports.
|
||||
doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping.
|
||||
gate up | down | status | logs -- manage a named long-lived egress gate.
|
||||
ps List sandbox containers.
|
||||
@@ -227,6 +234,10 @@ CONFIG
|
||||
Every default can also be set as an environment variable:
|
||||
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
|
||||
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ...
|
||||
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
|
||||
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
|
||||
(0 forbids the 'podman pull' used to learn a digest when skopeo is absent),
|
||||
AISB_SKOPEO.
|
||||
|
||||
EXIT STATUS
|
||||
The exit code of the in-container command. 77/78: the sandbox refused to start
|
||||
@@ -253,13 +264,15 @@ cfg_read_kv() { # `key = value` file
|
||||
key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")"
|
||||
val="${val%\"}"; val="${val#\"}"
|
||||
if [[ " $cfg_accum_keys " == *" $key "* ]]; then
|
||||
local -n ref="CFG_$key"
|
||||
ref+=("$val")
|
||||
# A nameref with a name nothing else uses: bash resolves namerefs by name, so a
|
||||
# local called `ref` anywhere nearby is a way to corrupt config reading.
|
||||
local -n cfg_target="CFG_$key"
|
||||
cfg_target+=("$val")
|
||||
continue
|
||||
fi
|
||||
case "$key" in
|
||||
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
|
||||
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args) CFG["$key"]="$val" ;;
|
||||
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args|base_ttl_days|base_auto_update) CFG["$key"]="$val" ;;
|
||||
*) warn "$file: unknown key: $key" ;;
|
||||
esac
|
||||
done < "$file"
|
||||
@@ -403,6 +416,18 @@ resolve_opts() {
|
||||
POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}"
|
||||
POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}"
|
||||
POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}"
|
||||
POL[base_ttl]="${AISB_BASE_TTL_DAYS:-$(cfgv base_ttl_days 7)}"
|
||||
[[ "${POL[base_ttl]}" =~ ^-?[0-9]+$ ]] || { warn "base_ttl_days must be an integer, using 7"; POL[base_ttl]=7; }
|
||||
POL[base_auto]="${AISB_BASE_AUTO_UPDATE:-$(cfgv base_auto_update 1)}"
|
||||
case "${POL[base_auto]}" in 0|false|no) POL[base_auto]=0 ;; *) POL[base_auto]=1 ;; esac
|
||||
# Preview modes must never reach the network: they report the pin that is already
|
||||
# recorded, which is also what makes the generated Containerfile testable.
|
||||
if (( OPT_DRY == 0 && OPT_PRINT == 0 && OPT_PRINT_EP == 0 && OPT_PRINT_CF == 0 )); then
|
||||
POL[base_digest]="$(base_pin_digest "${POL[base_image]}")"
|
||||
else
|
||||
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 0 1)"
|
||||
fi
|
||||
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
|
||||
POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}"
|
||||
POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
|
||||
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
|
||||
@@ -733,10 +758,120 @@ exec squid -N -f "$CONF"
|
||||
AISB_GATE
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# base image pinning
|
||||
# ===========================================================================
|
||||
# A tag is a moving pointer: "FROM ubuntu:24.04" does not name an image, it names
|
||||
# whatever Canonical points at that week. That makes a build unreproducible and, more
|
||||
# importantly, makes a security patch invisible -- you cannot tell an old image from a
|
||||
# patched one. So the tag is resolved to a digest once, recorded under
|
||||
# $AISB_CACHE_DIR/base/, and the digest is what goes into the Containerfile *and* into
|
||||
# the content hash: refreshing the base produces a new image rather than quietly
|
||||
# mutating one a sandbox may already be running.
|
||||
#
|
||||
# Freshness is a TTL, not a promise. Every AISB_BASE_TTL_DAYS days (default 7) the
|
||||
# digest is re-resolved, which is a manifest read rather than a download. Offline, or
|
||||
# with AISB_BASE_AUTO_UPDATE=0, the recorded digest is kept -- a stale pin is safer
|
||||
# than a mutable one. AISB_BASE_TTL_DAYS=0 re-resolves on every build; -1 never does.
|
||||
# `ai-sandbox update` does it on demand, `update --check` only reports.
|
||||
#
|
||||
# One deliberate asymmetry: an unpinned build is allowed, but it is announced as
|
||||
# UNPINNED, labelled into the image as such, and warned about by doctor. Silent
|
||||
# non-reproducibility is the failure mode this section exists to prevent.
|
||||
|
||||
base_state_path() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(sha12 "$1")"; }
|
||||
base_state_field() { local f; f="$(base_state_path "$1")"
|
||||
[[ -r "$f" ]] || return 0
|
||||
sed -n "s/^$2=//p" "$f" | head -1; }
|
||||
base_digest_recorded() { base_state_field "$1" digest; }
|
||||
|
||||
base_state_write() { # $1=ref $2=digest
|
||||
local f; f="$(base_state_path "$1")"
|
||||
mkdir -p "${f%/*}" || return 1
|
||||
( umask 077; printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" "$(date +%s)" > "$f" )
|
||||
}
|
||||
|
||||
base_age_days() { # $1=ref -> whole days since the pin was recorded, empty if unknown
|
||||
local at; at="$(base_state_field "$1" resolved_at)"
|
||||
[[ -n "$at" ]] || return 0
|
||||
printf '%s' $(( ( $(date +%s) - at ) / 86400 ))
|
||||
}
|
||||
|
||||
base_digest_fresh() { # $1=ref -> the recorded digest while it is inside the TTL
|
||||
local d age ttl="${POL[base_ttl]:-7}"
|
||||
d="$(base_digest_recorded "$1")"; [[ -n "$d" ]] || return 0
|
||||
(( ttl < 0 )) && { printf '%s' "$d"; return 0; } # -1: pinned until told otherwise
|
||||
age="$(base_age_days "$1")"; age="${age:-$ttl}"
|
||||
(( age <= ttl )) && printf '%s' "$d"
|
||||
return 0
|
||||
}
|
||||
|
||||
base_digest_online() { # $1=ref -> digest from the registry, empty when unreachable
|
||||
local ref="$1" d="" sk="${AISB_SKOPEO:-skopeo}"
|
||||
if command -v "$sk" > /dev/null 2>&1; then
|
||||
d="$("$sk" inspect --format '{{.Digest}}' "docker://$ref" 2>/dev/null | head -1)" || d=""
|
||||
fi
|
||||
# Without skopeo a pull is the only way to learn a digest, and it costs a download.
|
||||
# That is fine for an update and rude for an ordinary run, so it can be turned off
|
||||
# with AISB_BASE_PULL_FALLBACK=0 (the tests do exactly that, to stay offline).
|
||||
if [[ -z "$d" && "${AISB_BASE_PULL_FALLBACK:-1}" == 1 ]]; then
|
||||
if podman pull -q "$ref" > /dev/null 2>&1; then
|
||||
d="$(podman image inspect "$ref" --format '{{.Digest}}' 2>/dev/null)"
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$d"
|
||||
}
|
||||
|
||||
# Echoes the digest to pin to -- empty means unpinned. It is *not* written into POL
|
||||
# directly because callers use command substitution, and a global assigned in a
|
||||
# subshell is simply lost; combine it with base_ref_of() instead.
|
||||
# $1=ref $2=force (re-resolve now) $3=cache_only (never touch the network)
|
||||
base_pin_digest() {
|
||||
local ref="$1" force="${2:-0}" cache_only="${3:-0}" auto d=""
|
||||
if [[ "$ref" == *@* ]]; then printf '%s' "${ref#*@}"; return 0; fi # user pinned it
|
||||
auto="${POL[base_auto]:-1}"
|
||||
d="$(base_digest_fresh "$ref")"
|
||||
if [[ -z "$d" ]] && (( cache_only == 0 )); then
|
||||
if (( auto )) || (( force )); then
|
||||
log "resolving the current digest of $ref"
|
||||
d="$(base_digest_online "$ref")"
|
||||
if [[ -n "$d" ]]; then
|
||||
base_state_write "$ref" "$d" \
|
||||
|| log "warning: could not record the digest under $AISB_CACHE_DIR/base"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -n "$d" ]] || d="$(base_digest_recorded "$ref")"
|
||||
if [[ -z "$d" ]]; then
|
||||
(( cache_only )) || log "warning: $ref is UNPINNED -- no recorded digest and the registry was unreachable; the build is not reproducible"
|
||||
fi
|
||||
printf '%s' "$d"
|
||||
}
|
||||
|
||||
# A ref is name + tag, plus the digest when there is one. A ref the user pinned
|
||||
# themselves is passed through untouched rather than rebuilt from its parts.
|
||||
base_ref_of() {
|
||||
local ref="$1" digest="$2"
|
||||
[[ "$ref" == *@* || -z "$digest" ]] && { printf '%s' "$ref"; return 0; }
|
||||
printf '%s@%s' "$ref" "$digest"
|
||||
}
|
||||
|
||||
# Image tags are named after the base so a non-Ubuntu base does not lie about itself.
|
||||
image_tag_prefix() { local b="${POL[base_image]##*/}"; b="${b%%[:@]*}"; printf '%s' "${b:-base}"; }
|
||||
|
||||
containerfile() {
|
||||
cat <<AISB_CF
|
||||
# Generated by $PROG $VERSION -- edit bin/$PROG, not this file.
|
||||
FROM ${POL[base_image]}
|
||||
FROM ${POL[base_ref]:-${POL[base_image]}}
|
||||
|
||||
# Auditable by one 'podman image inspect' on a container found running weeks later.
|
||||
LABEL ai-sandbox=1 \\
|
||||
aisb.version="$VERSION" \\
|
||||
aisb.base="${POL[base_image]}" \\
|
||||
aisb.base-digest="${POL[base_digest]:-unpinned}" \\
|
||||
aisb.node="${POL[node_version]}" \\
|
||||
aisb.pi-package="${AISB_PI_PACKAGE}" \\
|
||||
aisb.pi-version="${POL[pi_version]}"
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \\
|
||||
LANG=C.UTF-8
|
||||
@@ -800,6 +935,17 @@ RUN set -eux; \\
|
||||
|
||||
RUN git config --system --add safe.directory '*' || true
|
||||
|
||||
# A tag answers "which build"; this answers "which squid, which nftables, which pi"
|
||||
# long after the build log has rotated. Readable from inside as \$AISB_MANIFEST.
|
||||
RUN mkdir -p /etc/aisb \\
|
||||
&& { echo "# $PROG $VERSION package manifest"; \\
|
||||
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
|
||||
echo "node=\$(node -v 2>/dev/null || echo unknown)"; \\
|
||||
npm ls -g --depth=0 --json 2>/dev/null \\
|
||||
| jq -r '.dependencies // {} | to_entries[] | "npm.\\(.key)=\\(.value.version // "?")"' \\
|
||||
|| echo 'npm=unavailable'; \\
|
||||
} > /etc/aisb/manifest.txt
|
||||
|
||||
COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh
|
||||
|
||||
@@ -809,7 +955,8 @@ ENV HOME=/home/sandbox \\
|
||||
npm_config_update_notifier=false \\
|
||||
GIT_TERMINAL_PROMPT=0 \\
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \\
|
||||
AISB=1
|
||||
AISB=1 \\
|
||||
AISB_MANIFEST=/etc/aisb/manifest.txt
|
||||
|
||||
WORKDIR ${POL[workdir]}
|
||||
ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]
|
||||
@@ -820,11 +967,20 @@ AISB_CF
|
||||
gate_containerfile() {
|
||||
cat <<AISB_GCF
|
||||
# Generated by $PROG $VERSION -- egress gate image.
|
||||
FROM ${POL[base_image]}
|
||||
FROM ${POL[base_ref]:-${POL[base_image]}}
|
||||
LABEL ai-sandbox=1 \\
|
||||
aisb.role=gate \\
|
||||
aisb.version="$VERSION" \\
|
||||
aisb.base="${POL[base_image]}" \\
|
||||
aisb.base-digest="${POL[base_digest]:-unpinned}"
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \\
|
||||
&& apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 \\
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 jq \\
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \\
|
||||
&& mkdir -p /etc/aisb \\
|
||||
&& { echo "# $PROG $VERSION gate package manifest"; \\
|
||||
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
|
||||
} > /etc/aisb/manifest.txt
|
||||
COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"]
|
||||
@@ -833,16 +989,18 @@ AISB_GCF
|
||||
|
||||
image_hash() {
|
||||
local h
|
||||
h="${POL[base_image]:-$AISB_BASE_IMAGE}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
|
||||
# base_ref, not base_image: a refreshed base digest has to change the tag, and a
|
||||
# bare tag change (24.04 -> 24.10) changes it too because the name is in the hash.
|
||||
h="${POL[base_ref]:-${POL[base_image]}}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
|
||||
h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
|
||||
# any change to the generated image definition produces a new tag
|
||||
h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)"
|
||||
h+="|$(containerfile | sha256sum | cut -d' ' -f1)"
|
||||
sha12 "$h"
|
||||
}
|
||||
image_ref() { printf '%s:u24-%s\n' "$AISB_IMAGE_REPO" "$(image_hash)"; }
|
||||
gate_image_ref() { printf '%s:u24-%s\n' "$AISB_GATE_IMAGE_REPO" \
|
||||
"$(sha12 "${POL[base_image]:-$AISB_BASE_IMAGE}|v${VERSION}|squid")"; }
|
||||
image_ref() { printf '%s:%s-%s\n' "$AISB_IMAGE_REPO" "$(image_tag_prefix)" "$(image_hash)"; }
|
||||
gate_image_ref() { printf '%s:%s-%s\n' "$AISB_GATE_IMAGE_REPO" "$(image_tag_prefix)" \
|
||||
"$(sha12 "${POL[base_ref]:-${POL[base_image]}}|v${VERSION}|squid")"; }
|
||||
|
||||
node_sha256() {
|
||||
[[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; }
|
||||
@@ -866,6 +1024,7 @@ ensure_image() { # $1=sandbox|gate
|
||||
entrypoint_script > "$ctx/aisb-entrypoint"
|
||||
chmod 755 "$ctx/aisb-entrypoint"
|
||||
local sha; sha="$(node_sha256)"
|
||||
[[ -n "$sha" ]] || log "warning: no SHA256 for Node ${POL[node_version]} (nodejs.org unreachable?) -- the tarball will NOT be verified. Set AISB_NODE_SHA256 to pin it yourself."
|
||||
build_in "$ctx" "$ref" \
|
||||
--build-arg "AISB_NODE_SHA256=${sha}" \
|
||||
--build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
|
||||
@@ -898,15 +1057,20 @@ build_in() {
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
local force=0 want_gate=0
|
||||
local force=0 want_gate=0 refresh_base=0
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--rebuild|-f) force=1; shift ;;
|
||||
--gate) want_gate=1; shift ;;
|
||||
--refresh-base|-B) refresh_base=1; shift ;;
|
||||
*) die "build: unknown argument $1" ;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
|
||||
if (( refresh_base )); then
|
||||
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
|
||||
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
|
||||
fi
|
||||
if (( force )); then
|
||||
podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true
|
||||
[[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true
|
||||
@@ -1176,6 +1340,9 @@ print_argv() {
|
||||
print_policy() {
|
||||
printf 'image=%s\n' "${POL[image_ref]}"
|
||||
printf 'gate_image=%s\n' "$(gate_image_ref)"
|
||||
printf 'base=%s\n' "${POL[base_image]}"
|
||||
printf 'base_digest=%s\n' "${POL[base_digest]:-unpinned}"
|
||||
printf 'base_age_days=%s\n' "$(base_age_days "${POL[base_image]}")"
|
||||
printf 'container=%s\n' "${POL[name]}"
|
||||
printf 'net=%s\n' "${POL[net]}"
|
||||
printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}"
|
||||
@@ -1305,6 +1472,29 @@ cmd_doctor() {
|
||||
local ref; ref="$(image_ref)"
|
||||
if podman image exists "$ref"; then chk PASS "sandbox image present: $ref"
|
||||
else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi
|
||||
|
||||
# Base pinning: an image built against a *different* digest than the one recorded now
|
||||
# is an image of the previous base -- worth saying out loud, that is how a patched
|
||||
# base quietly fails to arrive.
|
||||
local pdig; pdig="${POL[base_digest]:-unpinned}"
|
||||
if [[ "$pdig" == unpinned ]]; then
|
||||
chk WARN "base image ${POL[base_image]} is unpinned (no digest recorded, registry unreachable?)"
|
||||
else
|
||||
chk INFO "base pin: ${POL[base_image]} @ ${pdig:0:19}..., $(base_age_days "${POL[base_image]}") day(s) old (ttl ${POL[base_ttl]})"
|
||||
fi
|
||||
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
|
||||
chk OK "digest lookups available: $("${AISB_SKOPEO:-skopeo}" --version | cut -d' ' -f1-3)"
|
||||
else
|
||||
chk WARN "skopeo is missing: refreshing a base digest would need a full 'podman pull'"
|
||||
fi
|
||||
if podman image exists "$ref"; then
|
||||
local built; built="$(podman image inspect "$ref" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
|
||||
case "$built" in
|
||||
"") chk INFO "image $ref predates label tracking" ;;
|
||||
"$pdig") chk OK "image $ref was built on the pinned base" ;;
|
||||
*) chk WARN "image $ref was built on ${built:0:19}... but the pin is now ${pdig:0:19}... (run: $PROG update)" ;;
|
||||
esac
|
||||
fi
|
||||
if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)"
|
||||
else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi
|
||||
|
||||
@@ -1347,6 +1537,24 @@ cmd_doctor() {
|
||||
rm -rf "$d"
|
||||
fi
|
||||
|
||||
# With --deep, ask the registry whether the pin is still current. Deliberately
|
||||
# skopeo-only here: the podman fallback would download the whole base inside a
|
||||
# diagnostic, which is not what someone typing --deep signed up for.
|
||||
if (( deep )); then
|
||||
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
|
||||
local remote; remote="$(base_digest_online "${POL[base_image]}")"
|
||||
if [[ -z "$remote" ]]; then
|
||||
chk WARN "deep check: the registry is unreachable, cannot confirm the base is current"
|
||||
elif [[ "$remote" == "${POL[base_digest]:-}" ]]; then
|
||||
chk PASS "deep check: the base pin matches upstream"
|
||||
else
|
||||
chk WARN "deep check: upstream is ${remote:0:19}..., the pin is ${POL[base_digest]:-none} (run: $PROG update)"
|
||||
fi
|
||||
else
|
||||
chk INFO "deep check: skipped the upstream digest comparison (needs skopeo)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( deep )) && (($DOC_FAIL == 0)); then
|
||||
chk INFO "deep check: does the sandbox actually block non-allowlisted egress?"
|
||||
local out rc=0
|
||||
@@ -1422,6 +1630,63 @@ cmd_clean() {
|
||||
log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)"
|
||||
}
|
||||
|
||||
# Re-resolve the base digest on demand, and build whatever that implies. The old
|
||||
# image is deliberately left in place: if the new base misbehaves, the previous tag is
|
||||
# still there to fall back to, and pruning it is a decision for after the check.
|
||||
cmd_update() {
|
||||
local check=0 want_gate=1
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--check) check=1; shift ;;
|
||||
--no-gate) want_gate=0; shift ;;
|
||||
*) die "update: unknown argument $1 (try --check)" ;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
|
||||
local old_ref old_gref old_digest new_ref new_gref
|
||||
old_ref="$(image_ref)"; old_gref="$(gate_image_ref)"; old_digest="${POL[base_digest]:-none}"
|
||||
|
||||
if (( check )); then
|
||||
local remote; remote="$(base_digest_online "${POL[base_image]}")"
|
||||
printf 'base=%s\n' "${POL[base_image]}"
|
||||
printf 'recorded=%s\n' "${POL[base_digest]:-unpinned}"
|
||||
printf 'upstream=%s\n' "${remote:-unreachable}"
|
||||
if [[ -z "$remote" ]]; then
|
||||
printf 'status=could not reach the registry\n'; return 0
|
||||
elif [[ "$remote" == "${POL[base_digest]}" ]]; then
|
||||
printf 'status=up to date\nimage=%s\n' "$old_ref"; return 0
|
||||
else
|
||||
# Show the tag the update would produce without recording anything, so the
|
||||
# decision to rebuild is at least an informed one.
|
||||
POL[base_ref]="${POL[base_image]}@$remote"
|
||||
printf 'status=an update is available (a rebuild would produce a new tag)\n'
|
||||
printf 'rebuilt_image=%s\n' "$(image_ref)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
|
||||
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
|
||||
new_ref="$(image_ref)"; new_gref="$(gate_image_ref)"
|
||||
printf 'base %s\n was %s\n now %s\n' "${POL[base_image]}" "$old_digest" "${POL[base_digest]:-unpinned}"
|
||||
if [[ "$new_ref" == "$old_ref" ]]; then
|
||||
printf 'sandbox image unchanged: %s\n' "$new_ref"
|
||||
else
|
||||
printf 'sandbox image %s -> %s\n' "$old_ref" "$new_ref"
|
||||
fi
|
||||
if (( want_gate )); then
|
||||
if [[ "$new_gref" == "$old_gref" ]]; then
|
||||
printf 'gate image unchanged: %s\n' "$new_gref"
|
||||
else
|
||||
printf 'gate image %s -> %s\n' "$old_gref" "$new_gref"
|
||||
fi
|
||||
fi
|
||||
local ref gref
|
||||
ref="$(ensure_image sandbox)"; log "built $ref"
|
||||
if (( want_gate )); then gref="$(ensure_image gate)"; log "built $gref"; fi
|
||||
printf 'the previous image is kept for rollback; remove it with: podman rmi %s\n' "$old_ref"
|
||||
}
|
||||
|
||||
cmd_inspect() {
|
||||
mkdir -p "$AISB_STATE_DIR"
|
||||
load_config
|
||||
@@ -1447,6 +1712,7 @@ main() {
|
||||
gate) shift; cmd_gate "$@" ;;
|
||||
ps) shift; cmd_ps ;;
|
||||
clean) shift; cmd_clean "$@" ;;
|
||||
update) shift; load_config; session_init; resolve_opts; cmd_update "$@" ;;
|
||||
inspect) shift; cmd_inspect "$@" ;;
|
||||
version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;;
|
||||
help|-h|--help) usage ;;
|
||||
|
||||
Reference in New Issue
Block a user