Skip to content

test(e2e): warm the ghcr.io mirror before the tenant suites need it - #3768

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/e2e-warm-ghcr-mirror
Aug 14, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/e2e-warm-ghcr-mirror

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

The most expensive e2e flake, #3513, is a tenant worker missing the 18m node-Ready budget with zero Nodes registered. The in-guest capture in #3548 diagnosed one variant: the worker's kubelet image pull crawls to ghcr.io at 40-165 KB/s over the runner egress and does not finish inside the budget, Talos holds the kubelet service until its image arrives, so the node never registers and the tenant cilium HelmRelease then fails for want of a schedulable node rather than for any fault of its own.

The sandbox already runs an in-cluster pull-through mirror for ghcr.io, but a pull-through cache fetches a blob only when a client first asks for it, so the first tenant worker pays the cold fetch inside its node-Ready budget. Measured with the same fetch script against a local instance of the same registry image: 32m47s cold, 1.1s warm, for the same tags the failing runs stalled on.

This PR fills the cache from a Job started by the same install step that deploys the mirror, tens of minutes before the kubernetes suites run. It warms the two kubelet tags the suites actually select, resolved from the chart's version map in the same order the suites resolve it, and one platform per tag (the one the workers run, ~61 MiB each) - warming what nobody pulls would be a true addition to the same throttled egress the install itself is using.

The warm-up is best-effort by construction: a Job that fails, times out or never starts does not fail the install, and the suites behave exactly as they did before it existed. The Job itself exits non-zero on any missed tag, so its status stays honest.

The node-join failure diagnostics now answer the questions a red run used to leave open: whether a worker pulled through the mirror at all (counting GET and HEAD access lines per request, excluding the warm-up's own requests, which name themselves via user-agent), how long the mirror took to answer (server-side http.response.duration is the only after-the-fact signal separating a warm cache from a cold one, since the proxy stores blobs in the background and the Job reports success either way), and whether the warm-up itself got anywhere. The diagnostic section grows from five to seven bounded reads, moving its worst case from roughly 100s to roughly 150s inside the existing phase budget gate; unbudgeted diagnostic residuals stay tracked in #3666.

This addresses one variant of #3513 and does not close it: the zero-CSR class remains untouched, and a green suite after this change is not evidence the flake is gone. The 18m node-Ready budget is not raised.

Covered by 65 unit tests in hack/ghcr-mirror_test.bats: tag selection and ordering against the suites' own expressions, rejection of malformed and injected tag values, Job shape and restricted security context, the readiness wait as a wall clock, every failure path of the new diagnostics including an over-1-MiB log fixture that pins the duration filter against real log sizes, and the exclusion of the warm-up's own traffic from both the request count and the duration report.

Screenshots

No UI changes.

Downstream repositories

The diff is confined to e2e infrastructure under hack/: the mirror manifest, the warm-up library and its tests, the install step, and diagnostic labels. Walked the trigger map file by file - no chart values, no APIs, no documentation content, nothing any downstream repository consumes.

Release note

NONE

Summary by CodeRabbit

  • New Features

    • Added best-effort GHCR mirror warm-up during installation.
    • Preloads selected Kubernetes image manifests and blobs to improve cache readiness.
    • Warm-up failures safely fall back to direct image pulls.
  • Diagnostics

    • Improved request classification, timing, repository identification, and reporting of warm-up status and logs.
  • Tests

    • Expanded coverage for warm-up behavior, validation, retries, security settings, caching, logging, installation, and diagnostics.

@github-actions github-actions Bot added area/testing Issues or PRs related to testing (e2e, bats, unit tests) size/XXL This PR changes 1000+ lines, ignoring generated files labels Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 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: CHILL

Plan: Pro Plus

Run ID: bba80b92-a138-4f75-81f2-cd00b278ce67

📥 Commits

Reviewing files that changed from the base of the PR and between ce6bea2 and 829edfa.

📒 Files selected for processing (4)
  • hack/e2e-chainsaw/_lib/run-kubernetes.sh
  • hack/e2e-chainsaw/kubernetes-latest/chainsaw-test.yaml
  • hack/e2e-chainsaw/kubernetes-previous/chainsaw-test.yaml
  • hack/run-kubernetes-node-join_test.bats
🚧 Files skipped from review as they are similar to previous changes (3)
  • hack/e2e-chainsaw/_lib/run-kubernetes.sh
  • hack/e2e-chainsaw/kubernetes-latest/chainsaw-test.yaml
  • hack/e2e-chainsaw/kubernetes-previous/chainsaw-test.yaml

📝 Walkthrough

Walkthrough

The PR adds a best-effort GHCR mirror warm-up Job. It selects Kubernetes tags, prefetches amd64 kubelet manifests and blobs, wires warm-up into installation, and extends diagnostics and tests.

Changes

GHCR mirror warm-up

Layer / File(s) Summary
Warm-up tag selection and Job generation
hack/e2e-chainsaw/_lib/ghcr-mirror.sh, hack/ghcr-mirror_test.bats
The helper derives Kubernetes tags and generates a validated, bounded, non-root Job. The Job waits for mirror readiness, fetches amd64 manifests and blobs, and reports incomplete tags. Tests cover validation, retries, platform selection, timeouts, and fetch outcomes.
Warm-up orchestration and installation wiring
hack/e2e-chainsaw/_lib/ghcr-mirror.sh, hack/e2e-install-cozystack.bats, hack/e2e-ghcr-mirror.yaml, hack/ghcr-mirror_test.bats
Installation discovers the mirror Deployment and image, applies the warm-up Job, and ignores warm-up failures. Tests cover lookup failures, bounded execution, apply behavior, and installation invocation.
Mirror diagnostics and collection updates
hack/e2e-chainsaw/_lib/ghcr-mirror.sh, hack/e2e-chainsaw/_lib/run-kubernetes.sh, hack/e2e-chainsaw/kubernetes-latest/chainsaw-test.yaml, hack/e2e-chainsaw/kubernetes-previous/chainsaw-test.yaml, hack/ghcr-mirror_test.bats, hack/run-kubernetes-node-join_test.bats
Diagnostics filter warm-up requests, count worker GET and HEAD requests, report durations, and collect Job status and logs. E2E diagnostic descriptions and expectations include the warm-up Job.

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

Merge Risk: ⚪ Minimal · up to 829ed

This change adds best-effort e2e mirror warming and bounded diagnostics without introducing a supplied merge-blocking issue; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Installer
  participant MirrorDeployment
  participant KubernetesAPI
  participant WarmupJob
  participant GHCRMirror
  Installer->>MirrorDeployment: deploy mirror
  Installer->>KubernetesAPI: apply warm-up Job
  WarmupJob->>GHCRMirror: wait for readiness
  WarmupJob->>GHCRMirror: fetch kubelet manifests and blobs
  KubernetesAPI-->>Installer: Job status and logs
Loading

Suggested reviewers: kvaps, lllamnyp

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: warming the ghcr.io mirror before tenant end-to-end suites run.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/e2e-warm-ghcr-mirror

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Diagnostics, the injection guard and the best-effort path all hold up. I traced all seven kubectl failure modes by execution and every one returns 0 under set -eu, and the Job still exits non-zero on missed tags, so both halves of that claim are true at once. Tag selection matches what the suites actually do (sort_by(.) | .[-1] / .[-2]) against the real versions.yaml. 35 mutations against ghcr-mirror.sh, 25 caught, so the tests are real.

Requesting changes for the numbers and one test gap. Most are inline, two could not be anchored.

Budget worst case is 175s, not 150s. Reads go 5 to 7 and each one is timeout -k 5 20 (run-kubernetes.sh:1055-1056), so with the grace the ceiling is 125 to 175, not 100 to 150. Also cozy_diag_phase_has_time is a start gate, so the extra ~50s does not come from nowhere, it comes out of talos_image_cache_diagnose being declined more often. Worth saying that in the body instead of "stays inside the phase budget gate", which is true of the check but not of the phase.

ghcr_mirror_diagnose declares in its own header that it always returns 0, and nothing tests it. The line is outside the diff so I cannot put this inline, it is the final return 0 of that function. Changing it to return "$rc" survives all 63 tests, and a non-zero there aborts the { ... } >&2 group under the caller's set -e, dropping exactly the Job status and access log sections this PR adds.

One caveat missing from the body. The warm-up is not free on a run where the egress allow fails. It fires at install time gated only on the Deployment existing, but whether workers use the mirror is decided later in resolve_ghcr_mirror_endpoint, which returns empty when _apply_ghcr_mirror_egress_policy fails. On that run the mirror serves, the Job warms both tags (~244 MiB with the double read you document), workers fall back to public ghcr.io anyway, and the whole fetch is pure addition to the same throttled leg, concurrent with Pre-pull platform images. You apply this reasoning to arm64 but not to this case.

Separately, this file aborts mid-suite under cozytest when /bin/sh is bash, 44 of 63 tests never run. It reproduces on base so it is not yours, I put the reproduction on #3453.

# A `>= 2` assertion tolerated an accounting bug that gave up after 16s while the
# message still said 45; asking for most of the budget catches that direction.
# Derived from the same variable that sets the budget, so the two cannot drift.
want=$(( budget / 5 - 1 ))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the one I would fix before merge. want=$(( budget / 5 - 1 )) is 3 at budget=20, so a hardcoded n=0; while [ $n -lt 3 ] loop passes this test unchanged.

That leaves the wall clock claim from the commit message unpinned, and it is the central design claim of the readiness wait. At the shipped 600s budget the real loop probes around 120 times and the mutant probes 3.

A second case at a different budget would catch it, or assert the loop reacts to the clock rather than to a count.

Comment thread hack/e2e-install-cozystack.bats Outdated
# the install and the pre-pull step below are using, and if a worker cannot reach
# the mirror (assumption 2 in hack/e2e-ghcr-mirror.yaml) Talos falls back to public
# ghcr.io and the two then compete for it. That is why the warm set is the suites'
# two minors rather than the whole version map, which measured 634 MiB.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

634 MiB is the figure this PR retracts. ghcr-mirror.sh:338-343 puts the whole map at ~300 MiB at the one platform walk and calls 634 the accounting slip. Same stale number at ghcr-mirror_test.bats:685 and :707. Next person sizing GHCR_WARM_TAG_COUNT reads this and budgets against withdrawn data.

Comment thread hack/ghcr-mirror_test.bats Outdated
# This pins that a Job reaches kubectl, that the tag list word-splits into one
# argument per version rather than arriving as one blob, and that the set is
# the suites' set. Warming the whole map measured 634 MiB against ghcr.io, all
# of it across the leg this feature exists because of, four fifths for minors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Five tags with two warmed is three fifths, and ghcr-mirror.sh:341 says three fifths too.

*)
echo "WARNING: could not query the ghcr-mirror Deployment; skipping the warm-up. Reason: ${out:-none reported}" >&2 ;;
esac
return 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dropping this return 0 survives the suite. The test at ghcr-mirror_test.bats:1388 is named for skipping when the mirror was never deployed, but it only greps the message for not deployed, it never asserts that nothing was applied.

# answered, which is equally true of a cache hit and of a live
# upstream fetch. The blobstore write is distribution's business
# and this Job cannot observe it.
if get -T 600 -O /dev/null "\$base/blobs/\$blob"; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing pins this bound. -T 600 to -T 5 survives all 63 tests, and on the 40-165 KB/s leg this feature exists for a 5s inactivity timeout fails every blob.

@lexfrei

Copy link
Copy Markdown
Contributor Author

All five addressed, pushed as ce6bea281.

The wall-clock test now asserts the clock itself: elapsed >= budget around the script run, with the probe count kept as a secondary check. You were right that the derived bound collapses to 3 at the test's budget, and a hardcoded three-iteration loop now fails in 15s against the 20s budget.

The skip test asserts that nothing was applied, not just that the message printed. One subtlety surfaced while proving it: with the early return deleted, the flow used to die further down on an unreadable image and still applied nothing, so the fixture also had to clear every later obstacle for the mutation to actually reach kubectl.

The blob fetch timeout is pinned structurally in the emitted script (>= 600), so -T 5 no longer survives. The stale 634 MiB figures are gone from all three places, swept by grepping the literal, which is what I should have done when the first instance was corrected; the one remaining mention is the provenance note explaining where 634 came from.

While re-proving the exit contract I also found and closed its mirror image: five tests pinned the failure half, none pinned that a fully warm run exits zero, because every happy-path run swallowed the script's status with || true. There is a dedicated test for that now, so the suite is 65.

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All five inline points are closed and I confirmed each by mutation. What is still open is two sentences of the description, and one of my two unanchored points was wrong when I made it.

The wall-clock gap is closed, ghcr-mirror_test.bats:1313-1326 times the script and asserts elapsed against the budget before the count. Replacing the wall-clock loop with a hardcoded while [ $n -lt 3 ] reddens test 57, and the same mutant survived the whole suite before. The clock assertion cannot flake in the false-negative direction either, since the loop exits only once elapsed reaches the budget and start is taken after t0.

The stale 634 MiB is gone from all three consumers, grep -n 634 now returns only the provenance note. Three fifths agrees in both places. The not deployed early return is pinned by a valid image plus an apply capture asserted empty, and deleting the return 0 fails test 63. -T 600 is read out of the emitted script and required to be at least 600, and dropping it to 5 fails test 58.

My point 7 was wrong and it is my error, so it is worth stating plainly. I claimed return "$rc" in place of the final return 0 in ghcr_mirror_diagnose survives all 63 tests. It kills 11, because each of those does out=$(ghcr_mirror_diagnose 2>&1) and bats' set -e aborts the test on a non-zero assignment. A hard return 1 kills the same 11. The second half of my claim was wrong too, the final return 0 sits after the { ... } >&2 group so changing it cannot abort the group, and the caller is ghcr_mirror_diagnose || true, which absorbs a non-zero return and disables errexit inside the function anyway. Nothing to do here.

Two things still open, both in the description rather than the code.

Budget worst case is 175s, not 150s. Five bounded reads become seven, and with the 20s timeout and 5s grace the ceiling is 7 by 25. Even ignoring the kill grace the pair is 100 to 140, so roughly 100s to roughly 150s is neither figure. And cozy_diag_phase_has_time checks the deadline only on entry, so a section admitted with a second left runs its full 175s and the cost lands on the next gate. The tree is clean here, the phase inventory claims no per-section worst case at all, so this is a description fix.

The egress-allow caveat is still not written down, and it is no longer hypothetical. _apply_ghcr_mirror_egress_policy failing leaves the endpoint empty, so ghcr_registry_mirrors_block emits nothing and the tenant is never pointed at the mirror, while warm_ghcr_mirror fires gated only on the Deployment existing. I should have credited hack/e2e-install-cozystack.bats:106-112, which already documents the consequence for the reachability variant. What is missing is the policy-failure trigger.

Which brings me to the thing I would most want said. Both suites at this head warmed the cache cleanly and no worker ever reached the mirror. The run prints ghcr-mirror ready and egress allow applied, the worker-egress policy True, the Job Complete with warmed: v1.35.6 v1.34.9 and fetched 8, failed 0, and then no siderolabs/kubelet request appears in the mirror's access log, twice, once per suite, each followed by the node-join failure. None of that is caused by this PR and the diagnostics you added are what made it legible. But it does mean the 2x upstream read was paid by the Job and by nobody else on these runs, so the 2x is paid once either way is false on exactly them, and the feature merges with no run in which a worker benefited. Say that rather than leaving the reader of a future green suite to infer it.

Smaller notes. activeDeadlineSeconds: 3600 bounds node time across all four attempts, not completion, so a deadline kill SIGTERMs mid-loop and the three summary lines never print, leaving the diagnostic's --tail=5 showing fetched lines and no verdict on precisely the slow run where which-tag-is-warm is the question. Test 61 pins only that the field is numeric. The ~61 MiB and ~300 MiB figures are single-read arithmetic in a file that documents a 2x read four lines later, so the next person raising the tag count budgets half. And kubectl logs job/... reads one Pod of up to four, so the section can print a failed attempt's tail under a get job line reporting Complete.

The cozytest blind spot is unchanged and widened, 46 of 65 tests never execute on a host where /bin/sh is bash, up from 50 of 63. Cause is unchanged and pre-existing, the trace folds into $out. CI stays green because its /bin/sh is not bash. Stating it since this PR touches both the file and its library: it neither causes nor fixes that, and the 40 tests it adds are inert on such a host.

The in-sandbox pull-through cache fetches a blob only when a client
asks for it, so the first tenant worker's kubelet pull is a live fetch
from ghcr.io. That leg is what misses the node-Ready budget: measured
at 40-165 KB/s for a 61 MiB image, with two blob streams dying
mid-transfer after 7-8 MiB on an upstream HTTP/2 reset. Talos holds
the kubelet service in PreFunc until the pull completes, so the node
never registers, and the tenant cilium HelmRelease then fails for want
of a schedulable node rather than for any fault of its own.

Fill the cache from a Job started in the same install step that
deploys the mirror, tens of minutes before the kubernetes suites run.
The Job waits for the mirror to serve, then asks it for each kubelet
tag's index, walks the manifests it gets back, and discards the blob
bodies; distribution populates its blobstore on the way past.

It warms one platform per tag -- the one the workers run -- picked out of
the index by record, which keeps the walk a grep. Walking both platforms
was a true addition to the same throttled leg: arm64 is bytes nothing in
the sandbox pulls, and the digest ordering put it first for both tags, so
on the slow runs this exists for the deadline expired with the second tag
still cold.

It warms the two minors the suites select, not the whole version map.
The map would be ~300 MiB at the one-platform walk this ships (~61 MiB
per tag), three fifths of it for minors no suite can resolve, spent on
the same throttled egress the rest of the install is using. The tag
list still comes from the chart's map rather than a copy, ordered the
way the suites resolve it, so the set is theirs without restating
their expression.

The readiness wait is a wall clock, not a per-attempt count. Cilium
answers a Service with no ready endpoints by rejecting, so a probe
costs nothing when the mirror is absent and the full timeout when it
is merely slow; a constant was wrong in both directions across two
attempts at picking one.

Tags are refused by character class before the pattern is applied, so a
value cannot pass by having a valid first line: grep matches per line
and the pattern's anchor is end-of-line, not end-of-input.

The Job exits non-zero on any miss, so backoffLimit is a real bound
and a warm-up that reached nothing cannot report success. It names its
own requests, because the node-join diagnostic counts kubelet GETs in
the access log to tell a worker that pulled through the mirror from
one that fell back to public ghcr.io.

Naming them is only half of that. The same diagnostic now reports the
Job's status and result and the Job outlives the suites, because
excluding the warm-up without reporting it would leave a red run
unable to distinguish a warm cache from a warm-up that never started.
That counter also moved onto the access-line shape: registry:2.8.3
writes three lines carrying the path per request, so matching the path
counted one pull as three. It reads the repository from the same
variable the warm-up does, so a rename cannot leave the diagnostic
counting zero while the workers pull under the new name. GET and
HEAD both count: containerd resolves a manifest with a HEAD first,
so a GET-anchored count reports a pull that died before its first
GET as no request at all.

The failure path also prints how long the mirror took to answer the
worker. A warm cache and a cold one are otherwise indistinguishable in
the artifacts -- the Job reports success either way, because the proxy
stores the blob in the background and a completed GET is a different
event from a stored blob -- and server-side duration separates them by
orders of magnitude. It filters the log in the shell that read it: a
single argv element is capped at 131072 bytes on Linux regardless of
ARG_MAX, and probe traffic carries this log past that within about
twenty minutes of mirror uptime, so handing it to a helper would empty
the section on every run worth reading. Nothing is printed when the log
could not be read at all, because a heading with no lines under it
reads as a finding about the workers.

Measured against a local pull-through registry using the script the
builder emits: 32m47s cold, 1.1s warm for the same tags, and the
blobs it fetches are the same digests the failing runs stalled on.

A warm-up that fails, times out or never starts does not fail the
step, and the suites behave as they did before it existed. It is not
free, though: it pulls across the same throttled egress the install
is using, which is why the warm set is two tags and not five.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 1b4b6ff into main Aug 14, 2026
14 of 16 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/e2e-warm-ghcr-mirror branch August 14, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/testing Issues or PRs related to testing (e2e, bats, unit tests) size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants