From cac1703a94c5f9a99f0342c2d50a61c7b6fd41d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:51:34 +0200 Subject: [PATCH] hosting-image-mirror: copy the pinned platform images from ACR into the fleet registry when it lacks them (MeshWeaver.Plugins#1722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cr.meshweaver.cloud serves only what was pushed to it; CD publishes to ACR. A record pinning a tag the fleet registry lacked sent a human to `crane copy` as the registry's publisher (runbook step 4) — or, before #1546, into ImagePullBackOff at step 11. This command is that copy, for the portal image and (--also) the migration image, which must ride the same tag (Memex#141): present → KEPT, never re-pushed; digest compared with ACR's, a difference is the fact image_drift (a re-tagged upstream build is the operator's call). absent → the source is asked (absent there too → refused naming the sealed set to pin), crane copy runs, BOTH digests are read back and must match. Credentials: the publisher password from the vault and the ACR token from `az acr login --expose-token` reach crane on stdin (--password-stdin) into a per-process DOCKER_CONFIG — never argv, never printed, never a fact. The ACR short name derives from the source host; a non-ACR source is refused (the fleet's upstream is ACR). Rights: AcrPull on the source registry for the operator identity, not granted today — without it the step is RED naming the grant and the crane commands. Dockerfile: crane (go-containerregistry v0.20.3, per TARGETARCH) — nothing else in the image can do a manifest+blob copy. Tests: 35 cases against crane/az stubs that record logins by host+user only. Workflow: plan list, floor 24, stubs, tool check. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/hosting-operator.yml | 9 +- deploy/aks/INSTANCE-LIFECYCLE.md | 8 + deploy/aks/operator/Dockerfile | 10 +- deploy/aks/operator/bin/hosting-image-mirror | 163 ++++++++++++++++++ deploy/aks/operator/test/run-tests.sh | 95 ++++++++++ .../aks/operator/test/stubs/image-mirror/az | 19 ++ .../operator/test/stubs/image-mirror/crane | 38 ++++ 7 files changed, 337 insertions(+), 5 deletions(-) create mode 100755 deploy/aks/operator/bin/hosting-image-mirror create mode 100755 deploy/aks/operator/test/stubs/image-mirror/az create mode 100755 deploy/aks/operator/test/stubs/image-mirror/crane diff --git a/.github/workflows/hosting-operator.yml b/.github/workflows/hosting-operator.yml index 0ee18ba520..ffeed31e41 100644 --- a/.github/workflows/hosting-operator.yml +++ b/.github/workflows/hosting-operator.yml @@ -65,8 +65,8 @@ jobs: run: | set -euo pipefail tracked=$(git ls-files deploy/aks/operator/bin/ | wc -l | tr -d ' ') - if [ "$tracked" -lt 23 ]; then - echo "::error::only ${tracked} file(s) tracked under deploy/aks/operator/bin/ — expected 23 (20 commands + run.sh + _common.sh + _audit.jq). The floor is the REAL count — raise it with every command added, or a lost file passes." + if [ "$tracked" -lt 25 ]; then + echo "::error::only ${tracked} file(s) tracked under deploy/aks/operator/bin/ — expected 25 (22 commands + run.sh + _common.sh + _audit.jq). The floor is the REAL count — raise it with every command added, or a lost file passes." echo "::error::Almost certainly .gitignore matching this bin/ again. `git add` on an ignored path exits 0 and does NOTHING, so the loss is silent until a fresh checkout builds an image with no scripts in it." git check-ignore -v deploy/aks/operator/bin/run.sh || true exit 1 @@ -106,6 +106,7 @@ jobs: hosting-dns hosting-export hosting-federate + hosting-image-mirror hosting-inline-env-retire hosting-kv-ensure hosting-kv-purge @@ -192,7 +193,7 @@ jobs: # through to a REAL az/kubectl on a runner that has one. run: | set -euo pipefail - for f in deploy/aks/operator/test/stubs/pull-secret/az deploy/aks/operator/test/stubs/pull-secret/kubectl deploy/aks/operator/test/stubs/pv-resize/kubectl deploy/aks/operator/test/stubs/inline-env/kubectl deploy/aks/operator/test/stubs/kv-rotate/az; do + for f in deploy/aks/operator/test/stubs/pull-secret/az deploy/aks/operator/test/stubs/pull-secret/kubectl deploy/aks/operator/test/stubs/pv-resize/kubectl deploy/aks/operator/test/stubs/inline-env/kubectl deploy/aks/operator/test/stubs/kv-rotate/az deploy/aks/operator/test/stubs/image-mirror/az deploy/aks/operator/test/stubs/image-mirror/crane; do git ls-files --error-unmatch "$f" >/dev/null 2>&1 || { echo "::error::${f} is not tracked"; git check-ignore -v "$f" || true; exit 1; } [ "$(git ls-files -s "$f" | awk '{print $1}')" = "100755" ] || { echo "::error::${f} is not committed executable (mode 100755)"; exit 1; } done @@ -242,7 +243,7 @@ jobs: test -x /opt/hosting/bin/run.sh || { echo "run.sh missing"; exit 1; } test -f /opt/hosting/chart/Chart.yaml || { echo "chart missing — build context must be deploy/"; exit 1; } command -v hosting-deploy >/dev/null || { echo "hosting-* not on PATH"; exit 1; } - command -v helm kubectl az pg_dump jq >/dev/null || { echo "a required tool is missing"; exit 1; } + command -v helm kubectl az pg_dump jq crane >/dev/null || { echo "a required tool is missing"; exit 1; } test -f /opt/hosting/bin/_audit.jq || { echo "_audit.jq missing — hosting-audit has no detection"; exit 1; } echo "image carries: $(ls /opt/hosting/bin/hosting-* | wc -l) commands + chart $(grep ^version /opt/hosting/chart/Chart.yaml)" ' diff --git a/deploy/aks/INSTANCE-LIFECYCLE.md b/deploy/aks/INSTANCE-LIFECYCLE.md index 7f37c0a4f7..ec18d87091 100644 --- a/deploy/aks/INSTANCE-LIFECYCLE.md +++ b/deploy/aks/INSTANCE-LIFECYCLE.md @@ -117,6 +117,14 @@ rule is in the mesh's pure, unit-tested plan. What the scripts themselves guaran teardown is never retried from the top, where it would re-dump over a verified archive. - **`hosting-kv-ensure` never overwrites an existing master key.** Regenerating it would make every stored `enc:` value in that instance's database permanently unreadable. +- **`hosting-image-mirror` puts the pinned platform images into the fleet registry only when it + lacks them, and never re-pushes over what is there** (MeshWeaver.Plugins#1722). Portal AND + migration ride the same tag (Memex#141): a present tag is kept (its digest compared with ACR's, a + difference reported as `image_drift`), an absent one is `crane copy`-ed from ACR as the registry's + publisher and both digests must match before it reports; a tag absent from ACR too refuses + naming the sealed set to pin. The publisher password (vault) and the ACR token + (`az acr login --expose-token`) reach crane on stdin into a per-process `DOCKER_CONFIG` — never + argv, never printed. Needs `AcrPull` on the source registry for the operator identity. - **`hosting-export` never prints the URL it mints.** A user-delegation SAS is a bearer credential; it goes into a one-shot Secret the mesh reads once and deletes. - **`hosting-verify-catalog` proves the plugin mounts took.** An instance with green pods and an diff --git a/deploy/aks/operator/Dockerfile b/deploy/aks/operator/Dockerfile index 504d37e4e9..9eee8608f5 100644 --- a/deploy/aks/operator/Dockerfile +++ b/deploy/aks/operator/Dockerfile @@ -17,6 +17,10 @@ FROM mcr.microsoft.com/azure-cli:2.77.0 # configured: HostingOperator refuses with "no operator image configured" either way. ARG KUBECTL_VERSION=v1.31.4 ARG HELM_VERSION=v3.16.3 +# crane (go-containerregistry): hosting-image-mirror's registry-to-registry copy of the pinned +# platform images from ACR into the fleet registry (MeshWeaver.Plugins#1722). A manifest+blob copy +# needs a registry client; az, kubectl and helm are not one, and docker is not in this image. +ARG CRANE_VERSION=v0.20.3 # Supplied by BuildKit. The binaries below are per-architecture, and hard-coding amd64 produces an # image that builds fine on an arm64 laptop and then cannot exec kubectl. ARG TARGETARCH=amd64 @@ -35,7 +39,11 @@ RUN tdnf install -y --refresh postgresql tar shadow-utils ca-certificates jq \ && curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz" \ | tar -xz -C /tmp "linux-${TARGETARCH}/helm" \ && mv "/tmp/linux-${TARGETARCH}/helm" /usr/local/bin/helm \ - && rm -rf "/tmp/linux-${TARGETARCH}" + && rm -rf "/tmp/linux-${TARGETARCH}" \ + && case "${TARGETARCH}" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo "no crane build for ${TARGETARCH}"; exit 1 ;; esac \ + && curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \ + | tar -xz -C /usr/local/bin crane \ + && chmod +x /usr/local/bin/crane COPY aks/operator/bin/ /opt/hosting/bin/ RUN chmod +x /opt/hosting/bin/* && ln -s /opt/hosting/bin/hosting-* /usr/local/bin/ diff --git a/deploy/aks/operator/bin/hosting-image-mirror b/deploy/aks/operator/bin/hosting-image-mirror new file mode 100755 index 0000000000..c038c61fdb --- /dev/null +++ b/deploy/aks/operator/bin/hosting-image-mirror @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# hosting-image-mirror --registry --repository --tag +# --vault --publisher-secret +# [--also ]… [--source ] [--publisher ] +# +# Put the pinned platform images INTO the fleet registry when it lacks them (MeshWeaver.Plugins#1722). +# +# CD publishes every sealed set to ACR (meshweaver.azurecr.io); the fleet's pods pull from the +# registry the public instance hosts (cr.meshweaver.cloud, MeshWeaver#3353), which serves only what +# has been pushed to it. A record pinning a tag the fleet registry does not hold used to send a +# human to `crane copy` as the registry's publisher (runbook step 4) — or, before #1546, straight +# into ImagePullBackOff at step 11. This step is that copy, for the PORTAL image and, with --also, +# the MIGRATION image (Memex#141: the two must ride the same tag). +# +# per image: tag present in the fleet registry → KEPT (never re-pushed); its digest is compared +# with the source's and a difference is reported (`image_drift`) — a re-tagged build +# upstream is the operator's call, not this step's. +# absent → the source is asked (a tag absent THERE refuses, naming the sealed set to +# pin), `crane copy` runs, and BOTH digests are read back and must match. +# +# Credentials: the fleet registry's publisher password is read from the vault (--publisher-secret) +# and the ACR token comes from `az acr login --expose-token` under the operator identity (which +# needs AcrPull on the source registry). Both reach crane on STDIN (`--password-stdin`) into a +# private DOCKER_CONFIG that lives for this process — never argv, never printed, never a fact. +# shellcheck source=_common.sh +source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_common.sh" +# shellcheck disable=SC2034 # read by hosting::die in _common.sh, which shellcheck does not follow here +HOSTING_CMD="hosting-image-mirror" + +registry="" repository="" tag="" vault="" publisher_secret="" source_host="meshweaver.azurecr.io" publisher="publisher" +also=() +while [ $# -gt 0 ]; do + case "$1" in + --registry) registry="${2:-}"; shift 2 ;; + --repository) repository="${2:-}"; shift 2 ;; + --tag) tag="${2:-}"; shift 2 ;; + --also) also+=("${2:-}"); shift 2 ;; + --source) source_host="${2:-}"; shift 2 ;; + --vault) vault="${2:-}"; shift 2 ;; + --publisher-secret) publisher_secret="${2:-}"; shift 2 ;; + --publisher) publisher="${2:-}"; shift 2 ;; + *) hosting::die "unknown argument '$1'" ;; + esac +done +hosting::need_flag registry "$registry" +hosting::need_flag repository "$repository" +hosting::need_flag tag "$tag" +hosting::need_flag vault "$vault" +hosting::need_flag publisher-secret "$publisher_secret" +hosting::need_flag source "$source_host" +hosting::need_flag publisher "$publisher" +hosting::safe_host registry "$registry" +hosting::safe_host source "$source_host" +hosting::safe_name vault "$vault" +hosting::safe_name publisher-secret "$publisher_secret" +hosting::safe_name publisher "$publisher" +hosting::safe_name tag "$tag" +# A repository PATH: one or more plain segments joined by '/'. +safe_path() { + [[ "$2" =~ ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?(/[a-z0-9]([a-z0-9._-]*[a-z0-9])?)*$ ]] \ + || hosting::die "${1} '${2}' is not a repository path (lowercase segments joined by '/') — refusing to interpolate it into a command" +} +safe_path repository "$repository" +for extra in "${also[@]}"; do safe_path also "$extra"; done +# Only an Azure Container Registry is a source today: the ACR token comes from az, and the +# registry's short name is the host's first label. Another upstream is a design change, not a flag. +case "$source_host" in + *.azurecr.io) acr="${source_host%%.*}" ;; + *) hosting::die "source '${source_host}' is not an Azure Container Registry host (.azurecr.io) — the fleet's upstream is ACR, and its token comes from az" ;; +esac +[ "$registry" != "$source_host" ] || hosting::die "registry and source are both ${registry} — nothing to mirror" + +images=("$repository" "${also[@]}") +manual="" +for repo in "${images[@]}"; do manual="${manual}crane copy ${source_host}/${repo}:${tag} ${registry}/${repo}:${tag}; "; done +manual="az acr login --name ${acr} --expose-token (then crane auth login) and, as the registry's ${publisher} (password in vault ${vault} object ${publisher_secret}): ${manual}" + +if hosting::dry; then + for repo in "${images[@]}"; do + hosting::log "(dry run) would ensure ${registry}/${repo}:${tag} — read its digest as ${publisher}; if absent, copy it from ${source_host}/${repo}:${tag} and compare both digests" + done + hosting::say image_mirror dry-run + exit 0 +fi + +command -v crane >/dev/null 2>&1 || hosting::die "crane is not installed in this image — the copy is a registry-to-registry manifest+blob copy, and nothing else here can do it. Rebuild the operator image (deploy/aks/operator/Dockerfile installs it), or copy by hand: ${manual}" + +# A private docker config for this process: crane reads credentials from it, nothing else does, +# and it goes with the process. +umask 077 +DOCKER_CONFIG="$(mktemp -d)"; export DOCKER_CONFIG +trap 'rm -rf "$DOCKER_CONFIG"' EXIT + +# ── the fleet registry, as its publisher ───────────────────────────────────────────────────── +hosting::step "Sign in to the fleet registry as its publisher" +password="$(az keyvault secret show --vault-name "$vault" --name "$publisher_secret" --query value -o tsv 2>/dev/null)" \ + || hosting::die "could not read the publisher password ${publisher_secret} from vault ${vault} — nothing was copied. Grant the operator identity secret GET on it, or copy by hand: ${manual}" +[ -n "$password" ] || hosting::die "vault ${vault} object ${publisher_secret} is EMPTY — the registry accepts no push without it" +if ! printf '%s' "$password" | crane auth login "$registry" -u "$publisher" --password-stdin >/dev/null 2>&1; then + unset password + hosting::die "crane could not sign in to ${registry} as ${publisher} — the password in ${publisher_secret} does not match the registry's publisher hash (RegistrySpec.publisherPasswordBcrypt). Nothing was copied." +fi +unset password +hosting::log "signed in to ${registry} as ${publisher}" + +# digest_of — the manifest digest on STDOUT, or nothing (absent or unreadable). Never dies: +# callers decide what absence means at each side. +digest_of() { crane digest "$1" 2>/dev/null; } + +# ── what the fleet registry already holds ──────────────────────────────────────────────────── +hosting::step "Read the fleet registry" +present=() absent=() +declare -A have +for repo in "${images[@]}"; do + d="$(digest_of "${registry}/${repo}:${tag}")" + if [ -n "$d" ]; then have["$repo"]="$d"; present+=("$repo"); hosting::log "present ${registry}/${repo}:${tag} @ ${d:0:19}…" + else absent+=("$repo"); hosting::log "absent ${registry}/${repo}:${tag}"; fi +done + +# ── the source, only when something has to come from it ────────────────────────────────────── +mirrored=0 kept=${#present[@]} drift=0 +if [ ${#absent[@]} -gt 0 ]; then + hosting::step "Sign in to the source registry" + token="$(az acr login --name "$acr" --expose-token --query accessToken -o tsv 2>/dev/null)" \ + || hosting::die "az acr login --name ${acr} --expose-token failed — the operator identity needs AcrPull on ${source_host} (and to see it: Reader on the registry resource), which it does not hold today. Nothing was copied. Copy by hand: ${manual}" + [ -n "$token" ] || hosting::die "az acr login returned no token for ${acr} — nothing was copied" + if ! printf '%s' "$token" | crane auth login "$source_host" -u 00000000-0000-0000-0000-000000000000 --password-stdin >/dev/null 2>&1; then + unset token + hosting::die "crane could not sign in to ${source_host} with the ACR token — nothing was copied" + fi + unset token + hosting::log "signed in to ${source_host} as the operator identity" + + for repo in "${absent[@]}"; do + hosting::step "Mirror ${repo}:${tag}" + src="${source_host}/${repo}:${tag}" dst="${registry}/${repo}:${tag}" + sd="$(digest_of "$src")" + [ -n "$sd" ] || hosting::die "${src} does not exist — the pinned tag is not in the source registry either, so no copy can put it in ${registry}. Pin a tag of a SEALED set (the newest sealed main-cd set), or publish ${tag}. Nothing was copied for ${repo}$( [ "$mirrored" -gt 0 ] && echo "; ${mirrored} image(s) earlier in this run were")." + hosting::do crane copy "$src" "$dst" >/dev/null \ + || hosting::die "crane copy ${src} → ${dst} failed — nothing to undo (a partial copy is not a tag); re-request, or copy by hand: ${manual}" + dd="$(digest_of "$dst")" + [ "$dd" = "$sd" ] \ + || hosting::die "${dst} reads back as '${dd:-absent}' but the source is ${sd} — the copy did not land as the same manifest; refusing to report it mirrored" + hosting::log "mirrored ${dst} @ ${dd:0:19}… (same digest as ${src})" + mirrored=$((mirrored + 1)) + done + + # Present images are compared with the source too, now that the source is readable — a + # difference is a FACT (a re-tagged build upstream), never a re-push over what the pods run. + for repo in "${present[@]}"; do + sd="$(digest_of "${source_host}/${repo}:${tag}")" + if [ -n "$sd" ] && [ "$sd" != "${have[$repo]}" ]; then + hosting::log "drift ${registry}/${repo}:${tag} @ ${have[$repo]:0:19}… differs from ${source_host} @ ${sd:0:19}… — KEPT (never re-pushed); re-tag deliberately if the upstream build moved" + drift=$((drift + 1)) + fi + done +fi + +hosting::log "${registry}: ${mirrored} mirrored, ${kept} already present, ${drift} differing from ${source_host}" +hosting::say image_mirrored "$mirrored" +hosting::say image_present "$kept" +hosting::say image_drift "$drift" +hosting::say image_tag "$tag" diff --git a/deploy/aks/operator/test/run-tests.sh b/deploy/aks/operator/test/run-tests.sh index 782289a487..90c76180ca 100755 --- a/deploy/aks/operator/test/run-tests.sh +++ b/deploy/aks/operator/test/run-tests.sh @@ -324,6 +324,101 @@ case "$_ps_out" in *"pull_secret_verify=true"*) bad "a dry-run pull-secret never *) bad "a dry-run pull-secret reports its verify state" "said: ${_ps_out}" ;; esac unset _ps_out _ps_rc _ps_dir _ps_log _ns_line _sec_line +echo +echo "── hosting-image-mirror: the pinned images reach the fleet registry, once, verified ──" +# MeshWeaver.Plugins#1722 — cr.meshweaver.cloud serves only what was pushed to it; a record pinning +# a tag it lacks sent a human to `crane copy` (runbook step 4). The crane stub keeps a two-registry +# world (ref → digest) and records logins BY HOST AND USER only; the az stub answers the publisher +# password and the ACR token with sentinels. Asserted: present → kept (never re-pushed); absent → +# copied and both digests compared; absent upstream → refused naming the fix; no credential in any +# output or argv; a dry run touches nothing. +IM_STUBS="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/stubs/image-mirror" && pwd)" +im() { # im "" [env…] -- + local images="$1"; shift + local envs=() + while [ "$1" != "--" ]; do envs+=("$1"); shift; done; shift + _im_state="$(mktemp -d)"; printf '%b' "$images" > "$_im_state/images" + _im_out="$(env "${envs[@]}" PATH="$IM_STUBS:$PATH" HOSTING_IM_STATE="$_im_state" hosting-image-mirror "$@" 2>&1)"; _im_rc=$? + _im_log="$(cat "$_im_state/log" 2>/dev/null || true)"; _im_az="$(cat "$_im_state/az.log" 2>/dev/null || true)" + _im_images="$(cat "$_im_state/images" 2>/dev/null || true)" +} +IM_ARGS=(--registry cr.meshweaver.cloud --repository memex-portal-ai --tag 3.0.0-ci.8411 --also memex-migration --source meshweaver.azurecr.io --vault Systemorph --publisher-secret memexcloud-Registry-PublisherPassword --publisher publisher) +IM_SRC='meshweaver.azurecr.io/memex-portal-ai:3.0.0-ci.8411 sha256:f7e11bb9aaaa\nmeshweaver.azurecr.io/memex-migration:3.0.0-ci.8411 sha256:52ecc7cbbbbb\n' + +# Both absent → both copied from ACR, digests equal, as the publisher. +im "$IM_SRC" -- "${IM_ARGS[@]}" +[ "$_im_rc" -eq 0 ] && ok "image-mirror copies BOTH images the fleet registry lacks" || bad "image-mirror copies both" "exited ${_im_rc}: ${_im_out}" +case "$_im_images" in *"cr.meshweaver.cloud/memex-portal-ai:3.0.0-ci.8411 sha256:f7e11bb9aaaa"*"cr.meshweaver.cloud/memex-migration:3.0.0-ci.8411 sha256:52ecc7cbbbbb"*) ok "…portal AND migration land at the source's digests (Memex#141: same tag)" ;; *) bad "both landed" "registry holds: ${_im_images}" ;; esac +case "$_im_log" in *"auth login cr.meshweaver.cloud -u publisher --password-stdin"*"auth login meshweaver.azurecr.io -u 00000000-0000-0000-0000-000000000000 --password-stdin"*) ok "…signed in to the fleet registry as the publisher and to ACR with the token, both on stdin" ;; *) bad "logins" "crane saw: ${_im_log}" ;; esac +case "${_im_out}${_im_log}${_im_az}" in *NEVER-PRINTED*) bad "image-mirror never prints the publisher password or the ACR token, nor puts either on argv" "seen in: ${_im_out} ${_im_log} ${_im_az}" ;; *) ok "image-mirror never prints the publisher password or the ACR token, nor puts either on argv" ;; esac +case "$_im_az" in *"acr login --name meshweaver --expose-token"*) ok "the ACR short name is derived from the source host" ;; *) bad "acr name" "az saw: ${_im_az}" ;; esac +case "$_im_out" in *"::hosting:: image_mirrored=2"*"::hosting:: image_present=0"*"::hosting:: image_drift=0"*"::hosting:: image_tag=3.0.0-ci.8411"*) ok "the run reports mirrored=2 present=0 drift=0 and the tag" ;; *) bad "mirror facts" "said: ${_im_out}" ;; esac +rm -rf "$_im_state" + +# Both present → kept, ACR never asked, nothing copied. +im "${IM_SRC}cr.meshweaver.cloud/memex-portal-ai:3.0.0-ci.8411 sha256:f7e11bb9aaaa\ncr.meshweaver.cloud/memex-migration:3.0.0-ci.8411 sha256:52ecc7cbbbbb\n" -- "${IM_ARGS[@]}" +[ "$_im_rc" -eq 0 ] && ok "images already in the fleet registry are kept (a re-provision copies nothing)" || bad "present kept" "exited ${_im_rc}: ${_im_out}" +case "$_im_log" in *" copy "*) bad "…nothing is re-pushed" "crane saw: ${_im_log}" ;; *) ok "…nothing is re-pushed" ;; esac +case "$_im_az" in *"acr login"*) bad "…and ACR is not even asked" "az saw: ${_im_az}" ;; *) ok "…and ACR is not even asked" ;; esac +case "$_im_out" in *"image_mirrored=0"*"image_present=2"*) ok "…reported as present=2" ;; *) bad "present facts" "said: ${_im_out}" ;; esac +rm -rf "$_im_state" + +# Portal present, migration absent → only the migration is copied; the present portal is compared +# with ACR and a differing digest is reported as drift, never re-pushed. +im "${IM_SRC}cr.meshweaver.cloud/memex-portal-ai:3.0.0-ci.8411 sha256:OLDOLDOLD\n" -- "${IM_ARGS[@]}" +[ "$_im_rc" -eq 0 ] && ok "a partial set copies only what is missing" || bad "partial" "exited ${_im_rc}: ${_im_out}" +case "$_im_log" in *"copy meshweaver.azurecr.io/memex-migration:3.0.0-ci.8411 cr.meshweaver.cloud/memex-migration:3.0.0-ci.8411"*) ok "…the migration is copied" ;; *) bad "migration copied" "crane saw: ${_im_log}" ;; esac +case "$_im_log" in *"copy meshweaver.azurecr.io/memex-portal-ai"*) bad "…the present portal is NOT re-pushed even though it differs" "crane saw: ${_im_log}" ;; *) ok "…the present portal is NOT re-pushed even though it differs" ;; esac +case "$_im_out" in *"image_mirrored=1"*"image_present=1"*"image_drift=1"*) ok "…and the difference is reported as drift=1" ;; *) bad "drift fact" "said: ${_im_out}" ;; esac +rm -rf "$_im_state" + +# Absent upstream too → refused naming the sealed-set fix; nothing copied. +im "" -- "${IM_ARGS[@]}" +[ "$_im_rc" -ne 0 ] && ok "a tag absent from ACR as well is a REFUSAL — no copy can put it anywhere" || bad "absent upstream refuses" "exited 0: ${_im_out}" +case "$_im_out" in *"does not exist"*"SEALED set"*) ok "…naming the fix: pin a sealed set's tag" ;; *) bad "names the fix" "said: ${_im_out}" ;; esac +case "$_im_log" in *" copy "*) bad "…and copies nothing" "crane saw: ${_im_log}" ;; *) ok "…and copies nothing" ;; esac +rm -rf "$_im_state" + +# The identity cannot log in to ACR → refused naming AcrPull and the hand commands. +im "$IM_SRC" HOSTING_IM_ACR_DENIED=1 -- "${IM_ARGS[@]}" +[ "$_im_rc" -ne 0 ] && ok "no AcrPull on the source is a RED step" || bad "acr denied" "exited 0: ${_im_out}" +case "$_im_out" in *"AcrPull"*"crane copy meshweaver.azurecr.io/memex-portal-ai:3.0.0-ci.8411 cr.meshweaver.cloud/memex-portal-ai:3.0.0-ci.8411"*) ok "…naming the grant and the exact crane copy commands" ;; *) bad "acr message" "said: ${_im_out}" ;; esac +rm -rf "$_im_state" + +# The publisher password cannot be read → refused before anything. +im "$IM_SRC" HOSTING_IM_PUBLISHER_ABSENT=1 -- "${IM_ARGS[@]}" +[ "$_im_rc" -ne 0 ] && ok "an unreadable publisher password refuses" || bad "publisher absent" "exited 0" +case "$_im_log" in *"auth login"*) bad "…before any login" "crane saw: ${_im_log}" ;; *) ok "…before any login" ;; esac +rm -rf "$_im_state" + +# The fleet registry rejects the publisher password → refused naming the bcrypt hash on the record. +im "$IM_SRC" HOSTING_IM_LOGIN_FAIL=cr.meshweaver.cloud -- "${IM_ARGS[@]}" +[ "$_im_rc" -ne 0 ] && ok "a publisher password the registry rejects refuses" || bad "login fail" "exited 0" +case "$_im_out" in *"publisherPasswordBcrypt"*) ok "…naming the record field it must match" ;; *) bad "bcrypt hint" "said: ${_im_out}" ;; esac +rm -rf "$_im_state" + +# A copy that does not land as the same digest is a failure, not a pass. +im "$IM_SRC" HOSTING_IM_COPY_FAIL=1 -- "${IM_ARGS[@]}" +[ "$_im_rc" -ne 0 ] && ok "a failed copy fails the step" || bad "copy fail" "exited 0" +rm -rf "$_im_state" + +refuses_hard "image-mirror needs --registry" "missing required flag --registry" hosting-image-mirror --repository r --tag t --vault V --publisher-secret p +refuses_hard "image-mirror needs --tag" "missing required flag --tag" hosting-image-mirror --registry cr.test --repository r --vault V --publisher-secret p +refuses_hard "image-mirror needs --publisher-secret" "missing required flag --publisher-secret" hosting-image-mirror --registry cr.test --repository r --tag t --vault V +refuses_hard "image-mirror refuses a non-ACR source" "is not an Azure Container Registry host" hosting-image-mirror --registry cr.test --repository r --tag t --vault V --publisher-secret p --source ghcr.io +refuses_hard "image-mirror refuses a repository with a metacharacter" "is not a repository path" hosting-image-mirror --registry cr.test --repository 'r;id' --tag t --vault V --publisher-secret p +refuses_hard "image-mirror refuses a tag with a metacharacter" "is not a plain name" hosting-image-mirror --registry cr.test --repository r --tag 't`id`' --vault V --publisher-secret p +refuses_hard "image-mirror refuses registry == source" "nothing to mirror" hosting-image-mirror --registry meshweaver.azurecr.io --repository r --tag t --vault V --publisher-secret p +refuses_hard "image-mirror rejects unknown flags" "unknown argument" hosting-image-mirror --registry cr.test --repository r --tag t --vault V --publisher-secret p --nope 1 + +# Dry run: no vault, no login, no copy; says what it would do. +im "$IM_SRC" HOSTING_DRY_RUN=true -- "${IM_ARGS[@]}" +[ "$_im_rc" -eq 0 ] && ok "a dry-run image-mirror succeeds" || bad "dry run" "exited ${_im_rc}: ${_im_out}" +[ -z "$_im_log" ] && [ -z "$_im_az" ] && ok "…touching neither registry nor vault" || bad "dry run touches nothing" "crane: ${_im_log} az: ${_im_az}" +case "$_im_out" in *"::hosting:: image_mirror=dry-run"*) ok "…and reports dry-run" ;; *) bad "dry-run fact" "said: ${_im_out}" ;; esac +rm -rf "$_im_state" +unset _im_out _im_rc _im_log _im_az _im_images _im_state + echo echo "── the ::hosting:: contract the mesh parses ──────────────────────" emits "dry-run backup announces the object" "::hosting:: object=arch-1" \ diff --git a/deploy/aks/operator/test/stubs/image-mirror/az b/deploy/aks/operator/test/stubs/image-mirror/az new file mode 100755 index 0000000000..1d5a3ca1cd --- /dev/null +++ b/deploy/aks/operator/test/stubs/image-mirror/az @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# A stand-in `az` for hosting-image-mirror's behaviour tests (MeshWeaver.Plugins#1722): the vault +# read of the publisher password, and `acr login --expose-token`. Records every argv. +# HOSTING_IM_STATE directory: az.log +# HOSTING_IM_PUBLISHER_ABSENT=1 the publisher password object cannot be read +# HOSTING_IM_ACR_DENIED=1 `az acr login` fails as an identity without AcrPull does +set -uo pipefail +state="${HOSTING_IM_STATE:?the az stub needs HOSTING_IM_STATE}" +printf 'az %s\n' "$*" >> "$state/az.log" +case "${1:-} ${2:-} ${3:-}" in + "keyvault secret show") + [ "${HOSTING_IM_PUBLISHER_ABSENT:-0}" = "1" ] && { echo "ERROR: (SecretNotFound)" >&2; exit 3; } + printf 'fake-publisher-password-NEVER-PRINTED\n'; exit 0 ;; + "acr login "*) + [ "${HOSTING_IM_ACR_DENIED:-0}" = "1" ] && { echo "ERROR: (AuthorizationFailed) The client does not have authorization to perform action 'Microsoft.ContainerRegistry/registries/read'" >&2; exit 1; } + printf 'fake-acr-token-NEVER-PRINTED\n'; exit 0 ;; +esac +echo "az stub: unexpected invocation: $*" >&2 +exit 1 diff --git a/deploy/aks/operator/test/stubs/image-mirror/crane b/deploy/aks/operator/test/stubs/image-mirror/crane new file mode 100755 index 0000000000..74bf06539b --- /dev/null +++ b/deploy/aks/operator/test/stubs/image-mirror/crane @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# A stand-in `crane` for hosting-image-mirror's behaviour tests (MeshWeaver.Plugins#1722). Answers +# a two-registry world from a state directory and records every invocation — argv is how the test +# proves no credential ever travels on a command line; a password arrives on STDIN only. +# +# HOSTING_IM_STATE directory: log (every argv), images (lines ` `), logins +# HOSTING_IM_COPY_FAIL=1 every `copy` fails +# HOSTING_IM_LOGIN_FAIL= `auth login ` fails +set -uo pipefail +state="${HOSTING_IM_STATE:?the crane stub needs HOSTING_IM_STATE}" +printf 'crane %s\n' "$*" >> "$state/log" +touch "$state/images" "$state/logins" +case "${1:-}" in + auth) + [ "${2:-}" = "login" ] || { echo "crane stub: unexpected auth verb" >&2; exit 1; } + host="${3:-}"; user=""; stdin=0 + shift 3 + while [ $# -gt 0 ]; do case "$1" in -u) user="${2:-}"; shift 2 ;; --password-stdin) stdin=1; shift ;; *) shift ;; esac; done + [ "$stdin" = "1" ] || { echo "crane stub: a password must arrive on stdin (--password-stdin), never argv" >&2; exit 1; } + secret="$(cat)" + [ -n "$secret" ] || { echo "crane stub: empty password on stdin" >&2; exit 1; } + [ "${HOSTING_IM_LOGIN_FAIL:-}" = "$host" ] && { echo "unauthorized" >&2; exit 1; } + printf '%s %s\n' "$host" "$user" >> "$state/logins" # who logged in — never what with + exit 0 ;; + digest) + ref="${2:-}" + while read -r r d; do [ "$r" = "$ref" ] && { printf '%s\n' "$d"; exit 0; }; done < "$state/images" + echo "Error: fetching manifest ${ref}: MANIFEST_UNKNOWN: manifest unknown" >&2; exit 1 ;; + copy) + src="${2:-}" dst="${3:-}" + [ "${HOSTING_IM_COPY_FAIL:-0}" = "1" ] && { echo "Error: copying ${src}: unexpected status code 500" >&2; exit 1; } + grep -q "^${dst%%/*} " "$state/logins" || { echo "Error: pushing ${dst}: UNAUTHORIZED — not logged in to ${dst%%/*}" >&2; exit 1; } + d=""; while read -r r dd; do [ "$r" = "$src" ] && d="$dd"; done < "$state/images" + [ -n "$d" ] || { echo "Error: fetching ${src}: MANIFEST_UNKNOWN" >&2; exit 1; } + printf '%s %s\n' "$dst" "$d" >> "$state/images"; exit 0 ;; +esac +echo "crane stub: unexpected invocation: $*" >&2 +exit 1