Skip to content

fix(e2e): keep a failed image-mirror lookup from pinning the public factory - #3677

Merged
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/talos-factory-decision-indeterminate
Aug 9, 2026
Merged

fix(e2e): keep a failed image-mirror lookup from pinning the public factory#3677
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/talos-factory-decision-indeterminate

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

What this PR does

The tenant Kubernetes e2e suites decide once whether worker image pulls go to the in-cluster Talos mirror or to the public Image Factory, and that decision came from a plain kubectl get deploy talos-image-cache. A plain get exits non-zero for a connection refused, an Unauthorized and a real absence alike, so any blip during that one call read as "no mirror", and the answer went straight into the decision file every later tenant test reads. One unlucky moment sent every worker in the run to the public factory over live egress, which is the dependency the mirror exists to remove, and nothing in the log said so. The run went ahead against the flaky thing and the failures read as environment noise.

The lookup now asks with --ignore-not-found -o name. A real absence answers exit 0 with no output, which leaves a non-zero exit meaning only that the question failed, and the helper re-asks a couple of times before giving up. Only the empty answer turns the suite toward the public factory. An unanswered lookup warns, falls back for its own caller alone, and is deliberately not cached, so the next tenant test asks again instead of inheriting a guess.

The warning has to be backed by something, and kubectl's stderr alone is not enough. When the wall-clock bound kills an attempt, neither the bound nor the SIGTERMed kubectl writes a word, and that stall is precisely the failure the re-ask was written against, so a warning that sent the reader to kubectl's message would be pointing at an empty stretch of log exactly when the lookup mattered. The lookup therefore names the last attempt's exit status before giving up, in the vocabulary the diagnostic reads already use for 124 and 137, and kubectl's own text stays on stderr for the cases where there is any.

Each attempt is bounded twice, and the two bounds are deliberately not equal: timeout -k 5 40 around --request-timeout=30s. kubectl defaults the request timeout to 0, so a connection that establishes and then stalls has no deadline at all, and the flag on its own is not a wall-clock bound either, because kubectl retries discovery several times before it gives up. Re-asking an unbounded call three times would have multiplied a hang instead of riding out a blip.

The ten seconds between the two are load-bearing, and I only found out why by measuring rather than reasoning. Driven against a stub apiserver the three failure modes are not alike: a refused port answers in under a second and names the refusal, one that completes TCP but stalls mid-TLS hits kubectl's own handshake deadline and still produces text despite being killed, and a blackholed address (SYN dropped, no RST, which is what a wedged node or a netpol looks like) produces nothing at all until some deadline expires. That last mode is the one the headroom buys back. With both bounds at the same value the expiry that arrives first is always the wall-clock kill, so the blackhole case ends as a bare exit 124; with the wrapper ten seconds above, the request deadline ends the attempt and kubectl says what happened, and the wrapper stays as the backstop for a client that overruns its own deadline. Controlled A/B, same target and same inner bound, only the outer differs: 30 over 30 gives exit 124 and zero bytes, 40 over 30 gives exit 1 at the thirty second mark and a message naming the cause. The cost is ten more seconds per attempt on a path where the apiserver is already unreachable, inside a 50m op.

Worth stating separately, because it is a different question with a different answer: before this change the inner flag was not doing anything at all. --request-timeout=30s sat under a timeout of the same 30 seconds, and across all three measured modes it never once fired. The refused case never reached it, the TLS stall was ended by kubectl's own shorter handshake deadline, and the blackhole was ended by the wall-clock kill landing at the same instant. So the flag was decoration, and the comment above it claimed otherwise. That is a separate defect from the lost diagnostics, and it is fixed by the same separation rather than by adding anything.

Where timeout is not installed the read runs unwrapped instead of through it. Its exit 127 would otherwise count as an attempt that did not answer, three times over, so a missing local binary would pin the whole suite to the public factory while every message blamed the apiserver.

talos_image_cache_diagnose asks the same question on the node-join failure path, and #3676 landed its own three-way gate there while this branch was open. The two stay separate rather than sharing this helper. That one runs under a phase budget where re-asking spends what the tenant snapshot behind it needs, so it answers on a single attempt; this one runs on the happy path, where a re-ask costs a suite nothing and buys back the mirror. This branch leaves that gate as it was merged.

Unit tests put a throwaway kubectl and timeout on PATH and drive all three outcomes, the re-ask, the caching rule, the timeout-absent path and the killed-attempt path where nothing but the lookup itself can explain the failure: hack/cozytest.sh hack/talos-image-cache_test.bats. The stubs are files rather than shell functions because an external timeout binary execs a real file and would never see a shell override.

The re-ask is a retry loop, which docs/agents/e2e-testing.md restricts. It falls under the exemption there rather than the prohibition: what is retried is a network read with no product or test logic under it, and the deterministic steps the rule protects are untouched.

This relates to #3668 and closes the first door only. The decision is cached once at the end of the function regardless of which inner check produced the negative answer, and inside the "mirror is present" branch the rollout status wait and the three reads in _talos_image_cache_reachable_from_tenant still conflate "the mirror does not work" with "the probe could not be run", with the same suite-wide effect one layer down. The issue's own status comment says it stays open after this change, so there is no closing keyword here.

Three weaknesses this PR does not remove, named because each is a real thing a later reader will hit. The absent case in resolve_talos_image_factory_url is reached by else fall-through rather than by matching the token, so a fourth state added to the helper later would land in the branch that turns the suite toward the public factory rather than in the safe one; a test pins the present token against exactly that drift, but the shape stays fall-through. The lookup's own 30 and 5 are literals while the diagnostic reads next door run off a validated, overridable pair, and that is deliberate rather than an oversight: those knobs are named and documented for the dump budget, so wiring the lookup to them would let a test that turns the dumps down silently change which image factory a whole suite pulls from. And _TALOS_IMAGE_CACHE_QUERY_TRIES is the one knob in the file without that validation. A non-numeric value spins the loop forever rather than failing: the comparison sits in an if condition, so set -e is exempt, the branch is simply never taken, and the job burns to its ceiling with no output. Nothing outside the tests sets it, which is why it is here and not in the diff. Worth recording for whoever does fix it: the obvious remedy, reusing this file's own _talos_image_cache_seconds, does not work where the knob is assigned, because the validator is defined a hundred lines further down and the assignment runs at source time. The call resolves to nothing, the substitution yields an empty string, and an empty bound spins the same loop. A real fix has to move the definition up or validate inside the function at call time.

Screenshots

Not a UI change.

Downstream repositories

Walked the trigger map against the diff. It touches hack/e2e-chainsaw/_lib/talos-image-cache.sh and hack/talos-image-cache_test.bats and nothing else: no file under hack/ is moved or renamed, no make target changes behaviour, and no package, chart, CRD or values key is involved. The cozystack/ccp entry is the closest one and it does not fire.

  • No downstream repository is affected by this change

Release note

fix(e2e): a single failed apiserver lookup no longer sends a whole e2e run to the public Talos Image Factory

@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files area/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug labels Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The Talos image cache lookup now uses bounded retries and tri-state deployment results. Unknown status avoids caching fallback decisions. Tests cover command stubbing, retries, deadlines, caching, resolution, and diagnostics.

Changes

Talos image cache lookup

Layer / File(s) Summary
Tri-state lookup and diagnostics
hack/e2e-chainsaw/_lib/talos-image-cache.sh
Deployment queries retry with bounded requests. Established states use cached resolution. Unknown status uses the public factory without caching and emits a warning.
Lookup orchestration validation
hack/talos-image-cache_test.bats
Tests stub kubectl and timeout through PATH. Tests verify lookup states, retries, deadlines, timeout absence, factory resolution, caching, and diagnostics.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ImageFactoryResolver
  participant Timeout
  participant Kubectl
  ImageFactoryResolver->>Timeout: wrap deployment query when available
  Timeout->>Kubectl: query deployment with --ignore-not-found
  Kubectl-->>Timeout: return deployment status or failure
  ImageFactoryResolver->>ImageFactoryResolver: retry failed queries
  ImageFactoryResolver-->>ImageFactoryResolver: cache established state or use uncached fallback
Loading

Possibly related issues

  • cozystack/cozystack issue 3668 — Addresses transient deployment lookup failures and prevents indeterminate results from being cached.

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: failed image-mirror lookups no longer pin the public factory.
✨ 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/talos-factory-decision-indeterminate

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.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (2)
hack/e2e-chainsaw/_lib/talos-image-cache.sh (1)

262-273: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider a shorter lookup budget for the diagnose path.

talos_image_cache_diagnose treats present and unknown the same: both continue to the dump. Only absent changes behavior. The retries therefore change the outcome only when a later attempt returns a confirmed absence, which is rare on this path.

With the defaults, a wedged apiserver costs about 3 × 30s plus 2 × 5s before the dump starts, and the dump then issues more kubectl calls that also stall. Diagnose runs on an already-failing node-join, so this delay is added to a failure path.

You can keep the shared helper and lower the budget for this caller only.

♻️ Proposed change to bound the diagnose lookup
 talos_image_cache_diagnose() {
   local state
-  state=$(_talos_image_cache_deploy_state)
+  # One bounded attempt is enough here: present and unknown both dump, so only a
+  # confirmed absence changes the outcome.
+  state=$(_TALOS_IMAGE_CACHE_QUERY_TRIES=1 _talos_image_cache_deploy_state)
   if [ "$state" = absent ]; then

Note that _TALOS_IMAGE_CACHE_QUERY_TRIES is currently a plain variable, not a local in the helper, so the prefix assignment above works only because the helper reads it at call time. Confirm the override does not leak into later calls in the same shell.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/e2e-chainsaw/_lib/talos-image-cache.sh` around lines 262 - 273, In
talos_image_cache_diagnose, use a caller-scoped override to shorten the
_talos_image_cache_deploy_state lookup budget while preserving the shared
helper. Ensure the temporary _TALOS_IMAGE_CACHE_QUERY_TRIES value is restored
after the lookup so it cannot affect later calls in the same shell, while
retaining the existing absent and unknown handling.
hack/talos-image-cache_test.bats (1)

162-168: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the timeout stub fail loudly when it cannot find the wrapped command.

The stub shifts until $1 equals the literal string kubectl. If a future caller wraps any other binary with timeout, the loop consumes every argument and exec "$@" runs with no arguments. sh then exits 0 and produces no output. _talos_image_cache_deploy_state reads that as absent, which is the exact outcome the tri-state lookup exists to prevent, and the test still passes.

A generic skip of the leading option arguments is both more faithful to real timeout and fails loudly on a mismatch.

♻️ Proposed change to the `timeout` stub
     printf '%s\n' \
         '#!/bin/sh' \
         'printf "%s\n" "$*" >> "$STUB_TIMEOUT_CALLS"' \
-        'while [ $# -gt 0 ] && [ "$1" != kubectl ]; do shift; done' \
+        '# Skip the options and the duration, the way real timeout does.' \
+        'while [ $# -gt 0 ]; do' \
+        '  case "$1" in -*|[0-9]*) shift ;; *) break ;; esac' \
+        'done' \
+        '[ $# -gt 0 ] || { echo "stub timeout: no command to run" >&2; exit 125; }' \
         'exec "$@"' \
         > "$1/bin/timeout"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/talos-image-cache_test.bats` around lines 162 - 168, Update the timeout
stub in the test setup to skip leading option arguments generically, then
require a wrapped command before executing it; do not search specifically for
the literal kubectl argument. Make the stub exit nonzero with a clear diagnostic
when no command remains, preserving normal execution for timeout invocations
that include options followed by a command.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@hack/e2e-chainsaw/_lib/talos-image-cache.sh`:
- Around line 262-273: In talos_image_cache_diagnose, use a caller-scoped
override to shorten the _talos_image_cache_deploy_state lookup budget while
preserving the shared helper. Ensure the temporary
_TALOS_IMAGE_CACHE_QUERY_TRIES value is restored after the lookup so it cannot
affect later calls in the same shell, while retaining the existing absent and
unknown handling.

In `@hack/talos-image-cache_test.bats`:
- Around line 162-168: Update the timeout stub in the test setup to skip leading
option arguments generically, then require a wrapped command before executing
it; do not search specifically for the literal kubectl argument. Make the stub
exit nonzero with a clear diagnostic when no command remains, preserving normal
execution for timeout invocations that include options followed by a command.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: be268ba5-b1ea-4044-ba08-dde2622675fa

📥 Commits

Reviewing files that changed from the base of the PR and between b9846fe and 1106227.

📒 Files selected for processing (2)
  • hack/e2e-chainsaw/_lib/talos-image-cache.sh
  • hack/talos-image-cache_test.bats

@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/talos-factory-decision-indeterminate branch from 1106227 to 3598f2c Compare August 9, 2026 09:59

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
hack/e2e-chainsaw/_lib/talos-image-cache.sh (2)

198-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The bounds here are hardcoded while every other read in this file uses the shared knobs.

Lines 199-203 pin timeout -k 5 30 and --request-timeout=30s. Lines 435-440 and 460-470 use _TALOS_IMAGE_CACHE_READ_GRACE and _TALOS_IMAGE_CACHE_READ_TIMEOUT, which _talos_image_cache_seconds validates and which the comment at lines 271-284 says exist so the outer and inner bounds cannot drift apart.

Two consequences. A test that lowers _TALOS_IMAGE_CACHE_READ_TIMEOUT still waits the full path here: three attempts of 30s plus two delays, about 115s. An operator reading the file sees two different bounding conventions for the same kind of read.

Reuse the existing knobs, or add a named default beside _TALOS_IMAGE_CACHE_READ_TIMEOUT_DEFAULT and state in the comment why this read needs a longer bound than the diagnostic reads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/e2e-chainsaw/_lib/talos-image-cache.sh` around lines 198 - 204, The
talos-image-cache deployment read in the command -v timeout branch uses
hardcoded timeout values instead of the shared bounds. Update this read to use
_TALOS_IMAGE_CACHE_READ_GRACE and _TALOS_IMAGE_CACHE_READ_TIMEOUT, preserving
the existing _talos_image_cache_seconds validation and keeping both timeout
paths consistent; only introduce a separate named default if this read
intentionally requires a longer bound and document that distinction.

434-445: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The code does not match two claims in the PR description.

The description states that "the factory decision and diagnostic paths share the same helper". They do not. Lines 434-441 are a second inline copy of the query shape used at lines 198-204. Only the bounds and the retry policy differ. The comment at lines 158-162 argues the separation is deliberate, so the code may be right and the description wrong.

The description also states that "diagnostics dump available data when the lookup remains unanswered". Lines 442-445 return early on an unanswered lookup and dump nothing.

Confirm which behavior you intend. If the separation is intended, correct the description. If a shared helper is intended, extract a single-attempt primitive that takes the grace, the timeout and the request timeout as parameters, and let _talos_image_cache_deploy_state wrap it in the retry loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/e2e-chainsaw/_lib/talos-image-cache.sh` around lines 434 - 445, The
diagnostic lookup in the relevant Talos image-cache flow duplicates the factory
query and returns without dumping available data when the lookup fails. Confirm
the intended behavior: either update the PR description to accurately document
the deliberate separation and early return, or refactor the query into a shared
single-attempt helper parameterized by grace, timeout, and request timeout, then
have _talos_image_cache_deploy_state use it for retries and diagnostics dump
data after an unanswered lookup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@hack/e2e-chainsaw/_lib/talos-image-cache.sh`:
- Around line 192-193: Validate _TALOS_IMAGE_CACHE_QUERY_TRIES and
_TALOS_IMAGE_CACHE_QUERY_DELAY inside the query function, at call time rather
than during their top-level initialization. Reuse _talos_image_cache_seconds for
delay validation and add equivalent integer validation for the retry count
before entering the loop; invalid overrides must follow the function’s existing
fallback/error path instead of allowing repeated test and sleep failures.

---

Nitpick comments:
In `@hack/e2e-chainsaw/_lib/talos-image-cache.sh`:
- Around line 198-204: The talos-image-cache deployment read in the command -v
timeout branch uses hardcoded timeout values instead of the shared bounds.
Update this read to use _TALOS_IMAGE_CACHE_READ_GRACE and
_TALOS_IMAGE_CACHE_READ_TIMEOUT, preserving the existing
_talos_image_cache_seconds validation and keeping both timeout paths consistent;
only introduce a separate named default if this read intentionally requires a
longer bound and document that distinction.
- Around line 434-445: The diagnostic lookup in the relevant Talos image-cache
flow duplicates the factory query and returns without dumping available data
when the lookup fails. Confirm the intended behavior: either update the PR
description to accurately document the deliberate separation and early return,
or refactor the query into a shared single-attempt helper parameterized by
grace, timeout, and request timeout, then have _talos_image_cache_deploy_state
use it for retries and diagnostics dump data after an unanswered lookup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 70086e04-fdf6-4288-a499-20e6d7da7179

📥 Commits

Reviewing files that changed from the base of the PR and between 1106227 and 3598f2c.

📒 Files selected for processing (2)
  • hack/e2e-chainsaw/_lib/talos-image-cache.sh
  • hack/talos-image-cache_test.bats
🚧 Files skipped from review as they are similar to previous changes (1)
  • hack/talos-image-cache_test.bats

Comment on lines +192 to +193
_TALOS_IMAGE_CACHE_QUERY_TRIES="${_TALOS_IMAGE_CACHE_QUERY_TRIES:-3}"
_TALOS_IMAGE_CACHE_QUERY_DELAY="${_TALOS_IMAGE_CACHE_QUERY_DELAY:-5}"

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the retry knobs, or a non-integer override loops forever.

_TALOS_IMAGE_CACHE_QUERY_TRIES and _TALOS_IMAGE_CACHE_QUERY_DELAY come straight from the environment with no check. The rest of this file routes every duration through _talos_image_cache_seconds for exactly this reason.

If _TALOS_IMAGE_CACHE_QUERY_TRIES is not a bare integer, [ "$try" -ge "$_TALOS_IMAGE_CACHE_QUERY_TRIES" ] fails with "integer expression expected" and returns non-zero on every pass. The loop then never reaches the unknown exit, and sleep also errors each pass, so the loop spins without delay. That wedges the Chainsaw op rather than falling back.

Validate at call time inside the function. Do not validate at line 192, because _talos_image_cache_seconds is defined further down at line 303 and is not yet available when line 192 runs.

🛡️ Proposed fix
 _talos_image_cache_deploy_state() {
   local out rc try=1
+  local tries delay
+  tries=$(_talos_image_cache_seconds "${_TALOS_IMAGE_CACHE_QUERY_TRIES-}" 3 _TALOS_IMAGE_CACHE_QUERY_TRIES positive)
+  delay=$(_talos_image_cache_seconds "${_TALOS_IMAGE_CACHE_QUERY_DELAY-}" 5 _TALOS_IMAGE_CACHE_QUERY_DELAY)
   while :; do
@@
-    if [ "$try" -ge "$_TALOS_IMAGE_CACHE_QUERY_TRIES" ]; then
+    if [ "$try" -ge "$tries" ]; then
       printf 'unknown'
       return 0
     fi
     try=$((try + 1))
-    sleep "$_TALOS_IMAGE_CACHE_QUERY_DELAY"
+    sleep "$delay"
   done
 }

Also applies to: 213-218

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/e2e-chainsaw/_lib/talos-image-cache.sh` around lines 192 - 193, Validate
_TALOS_IMAGE_CACHE_QUERY_TRIES and _TALOS_IMAGE_CACHE_QUERY_DELAY inside the
query function, at call time rather than during their top-level initialization.
Reuse _talos_image_cache_seconds for delay validation and add equivalent integer
validation for the retry count before entering the loop; invalid overrides must
follow the function’s existing fallback/error path instead of allowing repeated
test and sleep failures.

@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/talos-factory-decision-indeterminate branch from 3598f2c to 4ed7d68 Compare August 9, 2026 10:20
The Talos image factory decision asks the apiserver once whether the
in-cluster mirror Deployment exists, reads any non-zero exit as an
absence, and caches that answer for the whole suite. A refused, throttled
or timed-out lookup therefore sends every tenant worker in the run to the
public Image Factory over live egress -- the dependency the mirror exists
to remove -- and the run reads as ordinary environment noise rather than
as a decision that was never actually made.

Route that question through one helper and pin what the helper owes its
caller: the three outcomes it must tell apart, the flags that make the
three-way answer possible at all, a deadline on every attempt, the
caching rule -- a real absence is resolved once and cached, an unanswered
lookup falls back for its own caller alone -- and that a missing local
timeout binary leaves the lookup asking the apiserver rather than
answering unknown three times over.

Two of those need saying carefully, because both concern what the caller
is told when the lookup fails. A killed attempt gets its own case, driven
by a wrapper that exits without running kubectl at all, so the lookup has
to account for why it gave up rather than relay someone else's words:
pinning the reason on a kubectl that failed loudly, as the sibling case
does, pins it only where it was never in doubt. And the wall-clock bound
is pinned as strictly greater than the request deadline beside it, which
is the property that lets kubectl reach its own deadline and say what
happened; level with it, the kill always lands first.

The helper is a placeholder in this commit and claims present
unconditionally, so the cases that need the distinction fail.

The stubs are files on PATH rather than shell functions because the
lookup runs under an external timeout binary, which execs a real file and
would never see a shell override. xtrace stays off for the rest of a test
that captures stderr: turned back on, a failing assertion wedges bats
instead of reporting it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
resolve_talos_image_factory_url asked whether the in-cluster Talos image
mirror exists with a plain `kubectl get deploy`, where a connection
refused, an Unauthorized and a real absence all arrive as the same
non-zero exit. It then cached that reading for the whole suite, so one
unlucky moment sent every tenant worker to the public Image Factory over
live egress -- the dependency the mirror exists to remove -- while the
run still looked ordinary.

Ask with --ignore-not-found -o name instead: a real absence answers exit
0 with no output, which leaves a non-zero exit meaning only that the
question failed, and re-ask a couple of times before giving up. Only the
empty answer turns the suite toward the public factory. An unanswered
lookup warns, falls back for its own caller alone, and is not written to
the decision file, so the next tenant test asks again rather than
inheriting a guess.

Each attempt is bounded twice, and the two bounds are deliberately not
equal. kubectl defaults --request-timeout to 0, so a connection that
establishes and then stalls has no deadline of its own; the flag alone is
not a wall-clock bound either, because kubectl retries discovery several
times before giving up. Measured against a stub apiserver, the three ways
this call fails behave differently: a refused port answers in under a
second and names the refusal, one that stalls mid-TLS gets kubectl's own
handshake deadline and still produces text, and a blackholed address
produces nothing until some deadline expires. With both bounds at the
same value the expiry that arrives first is always the wall-clock kill,
so --request-timeout can never fire and that last case stays silent. The
wrapper therefore sits ten seconds above it: the request deadline ends
the attempt and kubectl says why, and the wrapper remains the backstop
for a client that overruns its own deadline.

Where timeout is not installed the read runs unwrapped rather than
through it: its exit 127 would otherwise count as an attempt that did not
answer, three times over, and a missing local binary would pin the whole
suite to the public factory while every message blamed the apiserver.

kubectl's stderr is left alone, because it is the only thing separating
refused from unauthorized from stalled. It is still not guaranteed, since
a kill can land before the client has said anything, so the lookup also
names the last attempt's exit status before giving up, in the vocabulary
the diagnostic reads use for 124 and 137.

The diagnostic path asks the same question and keeps its own answer to
it. That one gives up after a single attempt, under a phase budget where
re-asking spends what the snapshot behind it needs; this one runs on the
happy path, where a re-ask costs a suite nothing and buys it the mirror.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/talos-factory-decision-indeterminate branch from 4ed7d68 to 5400f16 Compare August 9, 2026 10:32

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@hack/talos-image-cache_test.bats`:
- Around line 280-305: Extend the test at hack/talos-image-cache_test.bats lines
280-305 by calling resolve_talos_image_factory_url a second time and asserting
the stub counter reaches six, proving an unknown decision triggers three fresh
lookup attempts; retain the existing warning and uncached assertions. Extend the
test at hack/talos-image-cache_test.bats lines 333-347 by calling
resolve_talos_image_factory_url again and asserting the stub counter remains
one, proving a confirmed absence reuses the saved decision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 983456a8-2dac-4a5d-aa46-1944dda0076a

📥 Commits

Reviewing files that changed from the base of the PR and between 4ed7d68 and 5400f16.

📒 Files selected for processing (2)
  • hack/e2e-chainsaw/_lib/talos-image-cache.sh
  • hack/talos-image-cache_test.bats
🚧 Files skipped from review as they are similar to previous changes (1)
  • hack/e2e-chainsaw/_lib/talos-image-cache.sh

Comment on lines +280 to +305
@test "an undecidable lookup warns and is not cached for the rest of the suite" {
d=$(mktemp -d)
_TALOS_IMAGE_FACTORY_DECISION_FILE="$d/decision"
. hack/e2e-chainsaw/_lib/talos-image-cache.sh
_TALOS_IMAGE_CACHE_QUERY_DELAY=0
_stub_kubectl_dir "$d" 99 'deployment.apps/talos-image-cache'
# cozytest.sh runs each test under `set -x`, and xtrace shares the stderr
# this captures. Off for the call, so the assertions below read what the
# function said rather than an echo of the commands it ran, and left off
# afterwards: a test that turns it back on and then fails wedges `bats`
# instead of reporting, which costs a reader the failure they came for.
set +x
url=$(resolve_talos_image_factory_url 2>"$d/err")
cached=no
if [ -f "$d/decision" ]; then cached=yes; fi
err=$(cat "$d/err")
rm -rf "$d"
# Falling back for this one caller is fine; pinning that fallback for every
# later tenant test on the strength of a failed question is not.
[ -z "$url" ]
[ "$cached" = no ]
printf '%s' "$err" | grep -q 'could not determine' || { echo "an undecidable lookup must say so: [$err]" >&2; exit 1; }
# And it must say why. "could not determine" alone leaves an operator with a
# new silent failure in place of the old one: refused, Unauthorized and
# timed-out all read the same until kubectl's own message reaches the log.
printf '%s' "$err" | grep -q 'the server was unable to return a response' || { echo "the reason the lookup failed must reach the log: [$err]" >&2; exit 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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exercise cache behavior with a second resolver call.

Both tests inspect $d/decision after one call. They do not verify behavior for the next caller.

  • hack/talos-image-cache_test.bats#L280-L305: Call resolve_talos_image_factory_url again. Assert that the stub counter reaches six, so the second unknown result performs three new lookup attempts.
  • hack/talos-image-cache_test.bats#L333-L347: Call resolve_talos_image_factory_url again. Assert that the stub counter remains one, so confirmed absence uses the saved decision.
📍 Affects 1 file
  • hack/talos-image-cache_test.bats#L280-L305 (this comment)
  • hack/talos-image-cache_test.bats#L333-L347
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/talos-image-cache_test.bats` around lines 280 - 305, Extend the test at
hack/talos-image-cache_test.bats lines 280-305 by calling
resolve_talos_image_factory_url a second time and asserting the stub counter
reaches six, proving an unknown decision triggers three fresh lookup attempts;
retain the existing warning and uncached assertions. Extend the test at
hack/talos-image-cache_test.bats lines 333-347 by calling
resolve_talos_image_factory_url again and asserting the stub counter remains
one, proving a confirmed absence reuses the saved decision.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit dda339f into main Aug 9, 2026
16 of 17 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/talos-factory-decision-indeterminate branch August 9, 2026 11:06
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) kind/bug Categorizes issue or PR as related to a bug size/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant