test(e2e): warm the ghcr.io mirror before the tenant suites need it - #3768
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThe 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. ChangesGHCR mirror warm-up
Estimated code review effort: 4 (Complex) | ~60 minutes Merge Risk: ⚪ Minimal · up to 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
myasnikovdaniil
left a comment
There was a problem hiding this comment.
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 )) |
There was a problem hiding this comment.
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.
| # 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. |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
5e470d5 to
ce6bea2
Compare
|
All five addressed, pushed as 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 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 |
myasnikovdaniil
left a comment
There was a problem hiding this comment.
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]>
ce6bea2 to
829edfa
Compare
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.durationis 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
Summary by CodeRabbit
New Features
Diagnostics
Tests