Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/dockerhub-mirror.list
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Docker Hub images CI uses, mirrored BY DIGEST to ghcr.io/systemorph/dockerhub/<name>:<tag>.
#
# Why (Doc/Architecture/DockerHubInCi): GitHub-hosted runners pull from Docker Hub ANONYMOUSLY,
# and the anonymous limit is per egress IP, shared with every other tenant on that runner pool.
# Measured 2026-10-09: `toomanyrequests` red every test shard and the Chart Gate of core PR runs
# (#6381 among them). A CI step that needs one of these images resolves it through
# .github/scripts/dockerhub-mirror.sh `ref <name>:<tag>` and logs in to ghcr.io with the job's
# GITHUB_TOKEN (`packages: read`) — no stored credential, so it works on forks and on Dependabot.
#
# One line per image: <docker hub name>:<tag>@sha256:<digest>. The digest is the pin; the mirror
# copies exactly those bytes (`docker buildx imagetools create`, read back and compared), and
# `ref` hands the consumer `ghcr.io/systemorph/dockerhub/<name>:<tag>@<digest>`. Adding or moving
# an entry is a main merge: dockerhub-mirror.yml syncs on that push, BEFORE any pull request
# depends on the new line (a consumer of an un-synced line is red, naming it).
#
# Resolve a digest without spending the pull limit (a manifest HEAD is not counted):
# tok=$(curl -fsS "https://auth.docker.io/token?service=registry.docker.io&scope=repository:<name>:pull" | jq -r .token)
# curl -fsSI -H "Authorization: Bearer $tok" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json" \
# https://registry-1.docker.io/v2/<name>/manifests/<tag> | grep -i docker-content-digest

# The registry's token server — chart-gate `Bundle registry scripts (executed)`; the same digest
# deploy/helm/values.registry.example.yaml pins (the script refuses a mismatch).
cesanta/docker_auth:1.14.0@sha256:98e0307e0d2dcbafe10098814a97d248143b78ca478693f78c3c0d67fe776ab4

# The Loki binary that validates the rendered config — chart-gate `Observability values`; the tag
# is the appVersion of the pinned loki-stack chart (deploy/aks/scripts/check-observability-values.sh).
grafana/loki:2.9.3@sha256:eb92f1a439171542fd718f929fad38c917b3cad15ec830ba4742e2ba5ab03313
114 changes: 114 additions & 0 deletions .github/scripts/dockerhub-mirror.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
#
# dockerhub-mirror.sh — the route by which a CI job reaches a Docker Hub RUNTIME image it declares in
# .github/dockerhub-mirror.list (Doc/Architecture/DockerHubInCi). Base layers of images core BUILDS
# (`FROM` lines in path-filtered workflows) are out of its scope and listed on that page.
#
# dockerhub-mirror.sh ref <name>:<tag> print ghcr.io/systemorph/dockerhub/<name>:<tag>@<digest>
# for a line of .github/dockerhub-mirror.list; RED for an
# image the list does not declare
# dockerhub-mirror.sh sync copy every listed digest to the mirror (packages: write),
# read each one back and compare; RED on any mismatch
# dockerhub-mirror.sh --self-test the parsing and refusal arms, no network
#
# 🚨 No retries and no fallback to docker.io. A consumer that cannot reach the mirror is red, naming
# the line — a silent fall back to the anonymous pull is exactly the rate-limited path this replaces.
# For a declared image, `sync` is the only thing that touches Docker Hub, once per new digest, from a
# main/scheduled run.
set -euo pipefail

SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIST="${DOCKERHUB_MIRROR_LIST:-$SELF_DIR/../dockerhub-mirror.list}"
MIRROR="${DOCKERHUB_MIRROR_PREFIX:-ghcr.io/systemorph/dockerhub}"

# Emits "<name>:<tag> <digest>" per declared line; RED on a malformed one, so a typo can never
# read as "not declared", and RED on a second line for the same <name>:<tag> — otherwise `sync`
# would mirror both digests while `ref` hands consumers whichever came first.
entries() {
[ -f "$LIST" ] || { echo "::error::dockerhub-mirror: no list at $LIST" >&2; return 1; }
local line n=0 seen=" "
while IFS= read -r line || [ -n "$line" ]; do
n=$((n + 1))
line="${line%%#*}"; line="$(printf '%s' "$line" | tr -d '[:space:]')"
[ -n "$line" ] || continue
if [[ ! "$line" =~ ^([a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*):([A-Za-z0-9_][A-Za-z0-9._-]{0,127})@(sha256:[0-9a-f]{64})$ ]]; then
echo "::error::dockerhub-mirror: $LIST line $n is not <name>:<tag>@sha256:<64 hex>: '$line'" >&2
return 1
fi
if [[ "$seen" == *" ${BASH_REMATCH[1]}:${BASH_REMATCH[5]} "* ]]; then
echo "::error::dockerhub-mirror: $LIST line $n declares ${BASH_REMATCH[1]}:${BASH_REMATCH[5]} a second time — one digest per <name>:<tag>; move the existing line instead of appending" >&2
return 1
fi
seen="$seen${BASH_REMATCH[1]}:${BASH_REMATCH[5]} "
printf '%s:%s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[5]}" "${BASH_REMATCH[6]}"
done < "$LIST"
}

ref() {
local want="$1" all name digest
all="$(entries)" || return 1
while read -r name digest; do
if [ "$name" = "$want" ]; then printf '%s/%s@%s\n' "$MIRROR" "$name" "$digest"; return 0; fi
done <<< "$all"
echo "::error::dockerhub-mirror: '$want' is not declared in .github/dockerhub-mirror.list — CI pulls no Docker Hub image anonymously. Add <name>:<tag>@sha256:<digest> there (the file says how to resolve the digest without spending the pull limit), merge it, and let dockerhub-mirror.yml sync it before a pull request depends on it." >&2
return 1
}

sync() {
local all name digest target got rc=0
all="$(entries)" || return 1
[ -n "$all" ] || { echo "::error::dockerhub-mirror: the list declares no image — a sync over nothing has proven nothing" >&2; return 1; }
while read -r name digest; do
target="$MIRROR/$name"
if got="$(docker buildx imagetools inspect "$target@$digest" --format '{{json .Manifest.Digest}}' 2>/dev/null)" && [ "${got//\"/}" = "$digest" ]; then
echo "$name: already mirrored at $digest"
else
echo "$name: copying docker.io/$name@$digest → $target"
if ! docker buildx imagetools create --tag "$target" "docker.io/${name%:*}@$digest"; then
echo "::error::dockerhub-mirror: could not copy docker.io/${name%:*}@$digest to $target" >&2
rc=1; continue
fi
fi
# stderr stays in the log: an unreadable copy must say WHY it read as nothing.
if ! got="$(docker buildx imagetools inspect "$target" --format '{{json .Manifest.Digest}}')"; then got=""; fi
got="${got//\"/}"
if [ "$got" != "$digest" ]; then
echo "::error::dockerhub-mirror: $target reads back as '${got:-nothing}', not the declared $digest — a consumer pinned to the declared digest would not find it" >&2
rc=1
else
echo "$name: $target = $digest (read back)"
fi
done <<< "$all"
return "$rc"
}

self_test() {
local tmp fail=0 out d1 d2
tmp="$(mktemp -d)"
d1="sha256:$(printf '%064d' 1)"; d2="sha256:$(printf '%064d' 2)"
printf '# comment\n\nfoo/bar:1.2@%s # trailing\nbaz:pg17@%s\n' "$d1" "$d2" > "$tmp/ok.list"
printf 'foo/bar:latest\n' > "$tmp/bad.list"
printf 'foo/bar:1.2@%s\nfoo/bar:1.2@%s\n' "$d1" "$d2" > "$tmp/dup.list"
ok() { echo " ✅ $1"; }
ko() { echo " ❌ $1"; fail=1; }
out="$(DOCKERHUB_MIRROR_LIST="$tmp/ok.list" "$0" ref foo/bar:1.2)" || out=""
if [ "$out" = "ghcr.io/systemorph/dockerhub/foo/bar:1.2@$d1" ]; then ok "a declared image resolves to the mirror, digest-pinned"; else ko "a declared image resolves to the mirror, digest-pinned (got '$out')"; fi
out="$(DOCKERHUB_MIRROR_LIST="$tmp/ok.list" "$0" ref baz:pg17)" || out=""
if [ "$out" = "ghcr.io/systemorph/dockerhub/baz:pg17@$d2" ]; then ok "a single-segment name resolves too"; else ko "a single-segment name resolves too (got '$out')"; fi
if DOCKERHUB_MIRROR_LIST="$tmp/ok.list" "$0" ref foo/bar:9.9 2>/dev/null; then ko "an undeclared image is refused"; else ok "an undeclared image is refused"; fi
if DOCKERHUB_MIRROR_LIST="$tmp/ok.list" "$0" ref foo/bar:1 2>/dev/null; then ko "a tag that only prefixes a declared one is refused"; else ok "a tag that only prefixes a declared one is refused"; fi
if DOCKERHUB_MIRROR_LIST="$tmp/bad.list" "$0" ref foo/bar:latest 2>/dev/null; then ko "a line without a digest is RED, not 'undeclared'"; else ok "a line without a digest is RED, not 'undeclared'"; fi
if DOCKERHUB_MIRROR_LIST="$tmp/dup.list" "$0" ref foo/bar:1.2 2>/dev/null; then ko "a second digest for the same name:tag is RED, never first-wins"; else ok "a second digest for the same name:tag is RED, never first-wins"; fi
if DOCKERHUB_MIRROR_LIST="$tmp/none.list" "$0" ref foo/bar:1.2 2>/dev/null; then ko "a missing list is RED"; else ok "a missing list is RED"; fi
out="$(entries)" || out=""
if [ -n "$out" ]; then ok "the committed list parses and declares at least one image"; else ko "the committed list parses and declares at least one image"; fi
rm -rf "$tmp"
return "$fail"
}

case "${1:-}" in
ref) [ -n "${2:-}" ] || { echo "usage: dockerhub-mirror.sh ref <name>:<tag>" >&2; exit 2; }; ref "$2" ;;
sync) sync ;;
--self-test) self_test ;;
*) echo "usage: dockerhub-mirror.sh ref <name>:<tag> | sync | --self-test" >&2; exit 2 ;;
esac
78 changes: 78 additions & 0 deletions .github/workflows/dockerhub-mirror.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Docker Hub mirror

# Copies every image .github/dockerhub-mirror.list declares, BY DIGEST, to
# ghcr.io/systemorph/dockerhub/<name>:<tag> — the route by which CI reaches a Docker Hub image
# (Doc/Architecture/DockerHubInCi).
#
# WHY. GitHub-hosted runners pull from Docker Hub anonymously, against a limit counted per egress
# IP and shared with every other tenant of the runner pool. Measured 2026-10-09: `toomanyrequests`
# took down all six test shards and the Chart Gate of core pull requests (#6381 among them) — a
# red no code change could cause or cure. Retrying is not a fix (the limit is a window, not a
# blip); the fix is to stop asking Docker Hub from pull requests for the runtime images a job
# runs. For every image DECLARED in the list, this workflow is the only place CI touches docker.io,
# once per new digest, from main or the schedule. Not in scope: base layers of images core builds
# (path-filtered `FROM` lines) — the page lists them. The Chart Gate moves onto the mirror in the
# follow-up to the change that introduced this workflow.
#
# Consumers: `bash .github/scripts/dockerhub-mirror.sh ref <name>:<tag>` and a ghcr.io login with
# the job's GITHUB_TOKEN (`packages: read`). No stored credential — so forks and Dependabot pull
# the same way, and there is no second secret store to provision.
#
# 🛡️ NO SKIP-TRAPDOOR. The self-test runs on every trigger. The sync job's only condition is the
# EVENT (a pull request cannot write packages, and must not); it asserts its own input — a list
# that declares nothing is red — and reads every digest back.

on:
push:
branches: [main]
paths:
- .github/dockerhub-mirror.list
- .github/scripts/dockerhub-mirror.sh
- .github/workflows/dockerhub-mirror.yml
pull_request:
paths:
- .github/dockerhub-mirror.list
- .github/scripts/dockerhub-mirror.sh
- .github/workflows/dockerhub-mirror.yml
schedule:
# Daily: a deleted package or a manifest retention dropped is RESTORED the same day (sync copies
# any declared digest the mirror lacks); the run is red only when it cannot restore it.
- cron: "40 4 * * *"
workflow_dispatch:
Comment on lines +37 to +41

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding a tracking-issue reporter in this change. sync is self-restoring: on each daily run it copies any declared digest the mirror is missing. A failure it cannot fix also reaches its consumers immediately, because dockerhub-mirror.sh ref and the pull of the pinned digest go red in the Chart Gate step, naming the mirror line. So nothing stays silently unavailable. A sentinel issue is worth adding if a scheduled red ever goes unnoticed, but it is not needed for the fix.


permissions:
contents: read

concurrency:
group: dockerhub-mirror-${{ github.ref }}
cancel-in-progress: false

jobs:
self-test:
name: Mirror list parses, refusals hold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- name: Self-test
run: bash .github/scripts/dockerhub-mirror.sh --self-test

sync:
name: Every declared digest is on the mirror (read back)
needs: [self-test]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Sync
run: bash .github/scripts/dockerhub-mirror.sh sync
66 changes: 13 additions & 53 deletions .github/workflows/dotnet-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -911,60 +911,20 @@ jobs:
tar -I 'zstd --long=27' -xf "build-output-${{ matrix.shard }}.tar.zst"
rm -f "build-output-${{ matrix.shard }}.tar.zst"
df -h / | tail -1 | awk '{print "disk after extract: " $4 " free of " $2}'
# ── Acquire the Testcontainers images BEFORE the tests, as infrastructure ──
# 🚨 This step exists so a Docker Hub outage can never again masquerade as a
# mass TEST failure. Every PostgreSql fixture is a COLLECTION fixture, so when
# the image cannot be pulled, `InitializeAsync` throws and xUnit reports the
# failure against every test in the collection: run 31137723323 shard 5 logged
# 412 [FAIL]s, all of them the identical
# DockerApiException: Get "https://registry-1.docker.io/v2/": context deadline exceeded
# and NOT ONE a real defect. That shape is actively harmful — it reds the merge
# gate with a plausible-looking access-control failure list that cannot be
# reproduced locally (where the image is already in the local store), and it
# cost a full debugging session chasing tests that were never broken.
# ── No container image is pulled here — on purpose ────────────────────────
# 🚨 This job used to pre-pull `pgvector/pgvector:pg17` from Docker Hub, anonymously, on every
# shard. Since the Postgres suites left core (the test-move tranches, #2847 and its successors)
# NO core suite starts a container: nothing references Testcontainers.PostgreSql, and the one
# project naming MeshWeaver.Testcontainers (Memex.Portal.Shared.Test) exercises readiness logic
# against an in-process host. So the pull fetched an image nothing used — and on 2026-10-09 it
# was the ONLY red of six shards × several PRs (#6381 among them), Docker Hub answering
# `toomanyrequests` to the runner pool's shared egress IP. Retrying a per-IP window is not a
# fix; not asking is.
#
# Pulling here instead makes the dependency explicit and the diagnosis instant:
# the registry is a THIRD-PARTY fetch, so it gets bounded retries with backoff
# (a transient blip is not a code defect and must not be reported as one), and
# if it still cannot be fetched the shard fails HERE with an unambiguous infra
# error instead of 412 phantom test results.
#
# Testcontainers then finds the image in the local store and starts it without
# touching the network. Keep this list in sync with the fixtures — grep for
# `new PostgreSqlBuilder(` / `.WithImage(`.
- name: "Pre-pull Testcontainers images (infra, not a test)"
run: |
set -uo pipefail
# Only the images the fixtures name explicitly. Testcontainers' OTHER
# implicit Docker Hub pull — the ryuk resource reaper — is not listed
# because it is DISABLED for CI instead (see TESTCONTAINERS_RYUK_DISABLED
# on the next step): the runner VM is discarded wholesale after the job,
# so there is nothing for a reaper to reap, and not pulling it removes a
# second registry dependency rather than adding a second thing to retry.
# Its tag is also a function of the Testcontainers package version, so
# pinning it here would silently rot.
images="pgvector/pgvector:pg17"
rc=0
for img in $images; do
ok=0
for attempt in 1 2 3 4 5; do
if docker image inspect "$img" >/dev/null 2>&1; then
echo "$img: already in the local store"; ok=1; break
fi
if docker pull --quiet "$img"; then
echo "$img: pulled on attempt $attempt"; ok=1; break
fi
# Backoff: 5s, 10s, 20s, 40s. Docker Hub blips are seconds-to-minutes.
delay=$((5 * 2 ** (attempt - 1)))
echo "$img: pull attempt $attempt failed; retrying in ${delay}s" >&2
sleep $delay
done
if [ $ok -eq 0 ]; then
echo "::error::could not pull $img after 5 attempts — this is a REGISTRY/INFRA failure, not a test failure. Do not chase the tests that would have reported it."
rc=1
fi
done
exit $rc
# A suite that needs a Docker Hub image again does NOT restore an anonymous pull: it declares
# the image in .github/dockerhub-mirror.list, resolves it with
# `.github/scripts/dockerhub-mirror.sh ref <name>:<tag>` and logs in to ghcr.io with the job's
# GITHUB_TOKEN (`packages: read`) — Doc/Architecture/DockerHubInCi.
# ── Acquire the headless browser BEFORE the tests, as infrastructure ──────
# 🚨 Since #1230 the PDF export IS a headless browser: the document model was replaced
# by CSS Paged Media printed by Chromium, so every PDF test — the renderer's own, and the
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/main-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1970,11 +1970,17 @@ jobs:
echo "::notice::memex-control carries no version tag yet — this is the FIRST control image, so leg (c) (previous image against this run's migrated database) is not applicable. Every later run resolves a previous version and must pass it."
echo "args=--first-run" >> "$GITHUB_OUTPUT"
fi
# `--postgres` names the ACR copy of pgvector (the fleet's Docker Hub mirror, `az acr import`ed
# once — Memex deployments/aks/ci-runners, the image the runner sets' shared Postgres runs),
# never the script's laptop default `pgvector/pgvector:pg17`: this job is already logged in to
# ACR, and the anonymous Docker Hub pull is rate-limited per egress IP of the hosted runner
# pool (Doc/Architecture/DockerHubInCi) — a red on the delivery path no commit could cure.
- name: Acceptance — (a) empty DB, (b) broken NodeType row, (c) N−1 on the migrated DB
run: >
bash .github/scripts/control-image-acceptance.sh
--control "${{ env.ACR }}/memex-control:${{ env.STAGING }}"
--migration "${{ env.ACR }}/memex-migration:${{ env.STAGING }}"
--postgres "${{ env.ACR }}/dockerhub/pgvector/pgvector:pg17"
${{ steps.previous.outputs.args }}

# Tags the accepted image — `memex-control:<sha>`, `:main` — on ACR and the
Expand Down
1 change: 1 addition & 0 deletions src/MeshWeaver.Documentation/Data/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ Each theme starts with its introductory page, followed by related architecture t
- [Rendering a chart you are not allowed to fully configure](ChartDriftRenderWithoutSecrets) — 39 runs, 39 failures, zero verdicts: the check may hold two of the deploy's three value sources and the chart correctly refuses that subset; the placeholder that unblocks the render, the two-render proof that no compared object depends on it, and the bake gate that was off on both production namespaces the moment a verdict finally appeared
- [Configuring an instance from Aspire](ConfiguringAnInstanceFromAspire)
- [The Dependabot Secret Store](DependabotSecretStore)
- [Docker Hub in CI](DockerHubInCi) — hosted runners pull Docker Hub anonymously against a per-IP limit they share with every tenant; the runtime images CI jobs declare come from a digest-pinned GHCR mirror read with the job's own token, or the ACR copy where a job is already logged in; what is still out of scope
- [GitHub App Credentials](GitHubAppCredentials) — `meshweaver-cloud` writes to its own repo; every cross-repo READ mints from the read-only `fleet-reader`
- [Deployment env layers — what a record must be able to hold](DeploymentEnvLayers)
- [DeploymentInventory](DeploymentInventory)
Expand Down
Loading