Skip to content

fix(ci): pull the KWOK images once per run instead of once per job - #2497

Merged
mchmarny merged 2 commits into
mainfrom
fix/kwok-cache-registry-images
Aug 31, 2026
Merged

mchmarny merged 2 commits into
mainfrom
fix/kwok-cache-registry-images

Conversation

@mchmarny

Copy link
Copy Markdown
Member

Summary

Pull the two public images the KWOK lanes need once per run, in a dedicated priming job, and carry them to the matrix as tarballs in actions/cache — instead of having every one of ~127 matrix jobs pull them itself.

Motivation / Context

It is a per-IP throttle, not an outage, and not something retrying can fix. In run 33350102144:

Tier jobs Result
127 total
125 success
1 failure

125 jobs pulled public.ecr.aws/docker/library/registry:3.1.1 successfully at the same moment, from the same runner pool, in the same run. That rules out an outage, a bad pin, and a broken image. What is left is anonymous per-IP rate limiting under a 127-job concurrent matrix — which also explains the 2026-08-30 streak where a different lane failed each run.

#2496 fixed the retry schedule (it was giving up in ~17s while holding a 180s budget). That narrowed the window; it could not close it, because no retry schedule fixes a quota shared across the whole matrix. The fix is to stop making 127 requests.

So: one pull, one tarball, 127 loads from disk. preload_image already checks the host Docker cache before every pull attempt, so an image loaded from the tarball means the lane never contacts the registry at all.

Fixes: N/A
Related: #2483, #2496, #2480, #843

Deliberately Related, not a closing reference. This meets the first two of #2483's acceptance criteria — no unauthenticated third-party pull at job time, and the pull failure reason in the log (that one landed in #2496). The third is "several consecutive green KWOK runs on main", which can only be met by observation: with a 1-in-127 intermittent failure, one green run proves nothing. #2483 stays open until the runs accumulate.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Build/CI/tooling

Component(s) Affected

  • CLI (cmd/aicr, pkg/cli)
  • API server (cmd/aicrd, pkg/server)
  • Recipe engine / data (pkg/recipe)
  • Bundlers (pkg/bundler, pkg/component/*)
  • Collectors / snapshotter (pkg/collector, pkg/snapshotter)
  • Validator (pkg/validator)
  • Core libraries (pkg/errors, pkg/k8s)
  • Docs/examples (docs/, examples/)
  • Other: kwok/scripts/lib, .github/workflows/kwok-recipes.yaml, .github/actions/kwok-test

Implementation Notes

kwok/scripts/lib/image-cache.sh (new). Verbs key / file / save / load / settings. Sourceable by its unit harness with stubbed docker, executable by the workflow. Both workflow sites derive the cache key from image_cache_key here rather than from a value threaded through the reusable workflow — if the two could disagree, every restore would miss and the cache would silently do nothing while reporting success.

save is a gate, load is best effort. The asymmetry is deliberate. save runs once so the matrix never touches the registry, so a save that reports success without producing a usable tarball is worse than no cache at all — it sends all 127 jobs back to the pull with the gate lit green. Every failure path returns non-zero. load keeps preload_image's contract: a miss returns non-zero for the caller to shrug at, because the pull path is still intact behind it. A cold cache or a fork PR whose cache scope differs must degrade to today's behavior, not fail.

A miss is annotated, not silent. The load step emits ::warning:: on a miss. Without it, a cache key that ever drifts from the prime job's would leave every lane pulling again, green, with the fix inert and nothing to notice it by.

Partial tarballs are never published. save writes to scratch and publishes with a rename. A docker save killed partway (cancelled job, reclaimed runner) leaves bytes on disk; writing straight to the final path would let actions/cache upload that stump, every later run would take it as a hit, and docker load would fail in all 127 jobs — with the prime job green, because it did pull the image.

One prime job per image. actions/cache saves at the end of a job only when that job succeeds, so a single job priming both would discard a registry pull that had already worked if Gitea failed. Gitea is also restored only for the *-git lanes, which are the only ones install-infra.sh installs it for.

Summary false-green fix. A failed prime leaves every tier skipped, and the tier checks deliberately tolerate skipped (that is how a schedule-only run with no recipes passes). Without an explicit check on the prime result, the whole run would have reported green having validated nothing.

preload_pull_retry extraction. The retry loop moves out of preload_image so the prime job reuses the schedule tuned against the observed throttle in #2496 rather than growing a second copy. preload_image's behavior is unchanged — its existing suite passes unmodified, which is the regression guard.

discover timeout 5 → 10 min. The script-test block now runs ~90s locally before classification starts; those suites are deliberately wall-clock bound (they assert a hanging pull stays inside its deadline, which costs real seconds). A discovery job that times out would be exactly the self-inflicted red this PR exists to remove.

Testing

make qualify          # exit 0
make lint             # re-run after the final edits, clean
bash kwok/scripts/lib/image-cache_test.sh     # 45 cases
bash kwok/scripts/lib/preload-image_test.sh   # 32 cases, unmodified

All five KWOK script suites green (130 cases). The new suite is wired into the discover job alongside the existing four.

The new suite was mutation-tested. Four mutations of the implementation; three were caught immediately:

Mutation Caught by
Drop the post-docker load presence verify load-verifies-the-image-is-present
Key on the image name without hashing the full ref key-changes-with-the-tag, key-changes-with-the-registry
save returns 0 when the pull never lands save-pull-exhausted-fails, save-hanging-pull-fails
Write straight to the final path (no atomic publish) initially not caught

The fourth exposed a real gap: the partial-save case let docker save exit normally, so the function's own rm -f cleaned up the stump and a non-atomic implementation passed anyway. Added a case that kills the process mid-save, where no cleanup runs — that mutation now fails two assertions. Both cases are kept, and the one that only bites under a kill says so in a comment.

No Go source changed, so the coverage gate does not apply.

Risk Assessment

  • Low — Isolated change, well-tested, easy to revert
  • Medium — Touches multiple components or has broader impact
  • High — Breaking change, affects critical paths, or complex rollout

CI-only. Every new path degrades to current behavior on failure except the prime job itself, which is a deliberate gate. Reverting the two workflow files restores today's behavior with no other cleanup.

Not exercised locally: no Docker on the authoring machine, so the real docker save / docker load round-trip is covered by stubs only. The flags are standard, but the first CI run is the real proof — that is part of why this is a draft.

Rollout notes: The first run after merge is a cold cache: the prime job pulls, and the matrix restores from the cache it just wrote. Local runs (make kwok-test-all) are unaffected and still pull through preload_image.

Checklist

  • Tests pass locally (make test with -race)
  • Linter passes (make lint)
  • I did not skip/disable tests to make CI green
  • I added/updated tests for new functionality
  • I updated docs if user-facing behavior changed
  • Changes follow existing patterns in the codebase
  • Commits are cryptographically signed (git commit -S)

The KWOK lanes need two public images -- an in-cluster OCI registry and
Gitea for the *-git deployers -- and every matrix job pulled them itself.
In run 33350102144, 127 concurrent jobs pulled registry:3.1.1 at the same
moment from the shared runner egress IPs; 125 succeeded and the one that
was shed reddened main. That is a per-IP throttle, not an outage, so
retrying harder cannot fix it: #2496 narrowed the window, it did not
close it.

Pull each image once, in a prime-images job, and carry it to the matrix
as a tarball in actions/cache. Each lane loads from disk, and
preload_image then finds the image already in the host Docker cache and
never contacts the registry. On a warm cache not even the prime job
pulls.

Priming is a hard gate: if an image is genuinely unreachable, failing one
job in a minute beats 127 jobs each burning a rollout timeout. Loading is
best effort -- a miss falls back to preload_image's pull, and the kubelet
pull behind that -- so a cold cache or a fork PR degrades to today's
behavior rather than failing. A miss does emit a ::warning::, so a cache
key that ever drifts from the prime job's cannot leave every lane pulling
again, green, with the fix silently inert.

Also fixes a false green in the summary job: a failed prime leaves every
tier "skipped", and the tier checks deliberately tolerate "skipped", so
without an explicit check the run would report success having validated
nothing.

The retry loop moves out of preload_image into preload_pull_retry so the
prime job reuses the schedule tuned against the observed throttle rather
than growing a second copy. preload_image's behavior is unchanged; its
existing suite passes unmodified.

Signed-off-by: Mark Chmarny <mark@chmarny.com>
@mchmarny mchmarny added the theme/ci-dx CI pipelines, developer experience, and build tooling label Aug 31, 2026
@mchmarny mchmarny self-assigned this Aug 31, 2026
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 7424bacf-140a-4de6-ae7e-0fcd99d137e5

📥 Commits

Reviewing files that changed from the base of the PR and between a570720 and 76f5ee7.

📒 Files selected for processing (6)
  • .github/actions/kwok-test/action.yml
  • .github/workflows/kwok-recipes.yaml
  • kwok/README.md
  • kwok/scripts/lib/image-cache.sh
  • kwok/scripts/lib/image-cache_test.sh
  • kwok/scripts/lib/preload-image.sh

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.


📝 Walkthrough

Walkthrough

The change adds a Bash image-cache library with bounded save and load operations, pinned-image settings, cache-key generation, and CLI commands. It extracts deadline-based pull retries from preload_image. The KWOK workflow primes registry and Gitea image caches, restores them before validation, and reports priming failures. Unit tests cover cache operations and failure paths. Documentation describes the CI and local behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 76f5e

This change moves KWOK image pulls into a dedicated CI priming step and reuses cached tarballs across matrix jobs; no actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: arangogutierrez

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main CI change: KWOK images are pulled once per workflow run instead of once per job.
Description check ✅ Passed The description directly explains the image-priming workflow, cache behavior, retry extraction, tests, risks, and rollout impact described in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/kwok-cache-registry-images

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report ✅

Metric Value
Coverage 84.0%
Threshold 80%
Status Pass
Coverage Badge
![Coverage](https://img.shields.io/badge/coverage-84.0%25-brightgreen)

No Go source files changed in this PR.

@mchmarny
mchmarny marked this pull request as ready for review August 31, 2026 17:48
@mchmarny
mchmarny requested review from a team as code owners August 31, 2026 17:48
@mchmarny
mchmarny merged commit ca8556c into main Aug 31, 2026
68 checks passed
@mchmarny
mchmarny deleted the fix/kwok-cache-registry-images branch August 31, 2026 17:49
mchmarny added a commit that referenced this pull request Sep 28, 2026
The argocd-* KWOK lanes pull the Argo CD chart's Redis anonymously at job
time from ECR Public, which sheds the request under the per-IP throttle the
matrix already trips. Six lanes died on `429 Too Many Requests` for
ecr-public.aws.com/docker/library/redis:8.2.3-alpine in run 36290149406,
and every scheduled run since 2026-09-25 has been red the same way.

#2497 removed the two images install-infra.sh preloads from the critical
path; this covers the one the deployer stack pulls for itself, out of the
same ECR Public namespace the registry image was already cached from. The
chart's quay.io and ghcr.io images are left to the kubelet, neither having
been observed shedding a pull.

Follows the #2483 machinery: prime-images pulls once per run and caches the
tarball, each argocd-* lane restores and loads it, and install_argocd side-
loads it into the Kind node before Helm's 5m --wait.

The pin carries no renovate annotation on purpose. testing_tools is
renovate-managed, so an annotated entry would be bumped to the newest Redis
independently of the chart, caching an image nothing pulls and restoring
the 429. Nothing gates the pin against the chart, so a failed install now
renders the chart and reports the mismatch by name instead of leaving the
next reader to re-derive it from an ImagePullBackOff.

Pinning the ref surfaces ecr-public.aws.com to tools/registry-inventory,
which gates structured sources only. This is not new egress: the chart has
always pulled that host in-cluster, but as a rendered chart image it sat
outside the gate's scope. Allowlisted with that justification, and
docs/contributor/registry-egress.md regenerated to match.

Fixes: #2501
Signed-off-by: Mark Chmarny <mark@chmarny.com>
mchmarny added a commit that referenced this pull request Sep 28, 2026
The argocd-* KWOK lanes pull the Argo CD chart's Redis anonymously at job
time from ECR Public, which sheds the request under the per-IP throttle the
matrix already trips. Six lanes died on `429 Too Many Requests` for
ecr-public.aws.com/docker/library/redis:8.2.3-alpine in run 36290149406,
and every scheduled run since 2026-09-25 has been red the same way.

#2497 removed the two images install-infra.sh preloads from the critical
path; this covers the one the deployer stack pulls for itself, out of the
same ECR Public namespace the registry image was already cached from. The
chart's quay.io and ghcr.io images are left to the kubelet, neither having
been observed shedding a pull.

Follows the #2483 machinery: prime-images pulls once per run and caches the
tarball, each argocd-* lane restores and loads it, and install_argocd side-
loads it into the Kind node before Helm's 5m --wait. The chart renders one
distinct Redis ref with redis-ha off, and the argocd-redis container is
imagePullPolicy: IfNotPresent, so the side-load is what the kubelet uses.

The pin carries no renovate annotation on purpose. testing_tools is
renovate-managed, so an annotated entry would be bumped to the newest Redis
independently of the chart, caching an image nothing pulls and restoring
the 429. Nothing gates the pin against the chart, so a failed install
renders the chart and reports the mismatch by name instead of leaving the
next reader to re-derive it from an ImagePullBackOff. That render is bound
by timeout: it re-fetches the chart the install just failed on, and an
unbounded stall there would replace exit 30 with a job cancellation.

Pinning the ref surfaces ecr-public.aws.com to tools/registry-inventory,
which gates structured sources only. This is not new egress: the chart has
always pulled that host in-cluster, but as a rendered chart image it sat
outside the gate's scope. Allowlisted with that justification, and
docs/contributor/registry-egress.md regenerated to match.

kwok/README.md described the cache as covering two images; it now covers
three, with the Redis pin's provenance and the per-lane restore gating.

Fixes: #2501
Signed-off-by: Mark Chmarny <mark@chmarny.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci size/XL theme/ci-dx CI pipelines, developer experience, and build tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant