Skip to content

test(e2e): capture worker CPU throttling counters on node-join failure - #3723

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/e2e-capture-worker-cpu-throttling
Aug 12, 2026
Merged

test(e2e): capture worker CPU throttling counters on node-join failure#3723
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/e2e-capture-worker-cpu-throttling

Conversation

@lexfrei

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

Copy link
Copy Markdown
Contributor

What this PR does

When a tenant worker misses the node-Ready deadline, the failure bundle currently cannot tell two different causes apart: a worker held at its own CFS ceiling, and a worker losing host CPU it was entitled to. Both look identical from outside the Pod, and node-level utilisation answers neither, because it shows what the node used rather than what the container was allowed.

This adds a collector that reads the CFS counters and the ceiling itself from the kubelet's cAdvisor endpoint, for every tenant worker on every node in the sandbox. The counters are the only place that difference is recorded, and nothing else in the tree collects them for these workers.

The motivating measurement is on nightly run 31290999592: the compute container reports 0.968 and 0.920 core against a limits.cpu of 1, which is 92-97% of a one-core ceiling. Whether the container ever actually met that ceiling is exactly what the artifact cannot say, and what these counters answer.

Most of the change is the failure taxonomy rather than the read. An empty capture is the dangerous outcome here, because zero bytes reads as a container that never hit its ceiling -- the conclusion the collector exists to stop a reader reaching by default. So every way the capture can come up short gets its own sentence: the kubelet was not read, the read was cut short part way, the metric stream could not be read back on this runner, the kubelet answered and carried no series for this namespace, the namespace was there and the worker Pods were not. The one non-failure outcome is written down too: a container with no CPU limit puts a single line on the wire, which is also what a truncated read leaves behind, so it says which one it is.

Ordering

The collector runs first among the gated collectors, ahead of the guest serial console. Its ceiling is four bounded reads, about 100s of a 480s phase budget, so on a slow run the console capture starts that much later. That is a deliberate trade and not a side effect of putting the cheapest first: cost is not what settles the order, survivability of the answer is. The console evidence cannot be re-taken, and this collector's question has no other answer in the tree, so both go ahead of the captures whose state is partly recoverable from reads above them.

Known limits, named rather than left to be found

  • The outcome ladder enumerates combinations of read status, filter status and result emptiness. Every new source of partial output needs a new rung, and that has already happened twice while this branch was in review. Deriving the label from the three axes instead would make a new source land in an existing axis.
  • The uncapped note is decided over the whole per-node file, so one quota line anywhere in it suppresses the note. That is the conservative direction -- it never claims uncapped over a file that shows a ceiling -- but it makes the note rare.
  • The messages say see read-error.log while the file on disk is <node>.read-error.log. That is the convention the neighbouring serial-console collector already uses, and changing one side alone would make the two inconsistent.
  • The mkdir -p for the report directory is unchecked, as it is in four neighbouring collectors in the same file. Worth fixing across the file rather than in one collector.
  • The section letters in the failure block no longer track emission order. They already did not before this change; this adds one more entry to the sequence.
  • The count of bats files sourcing the library, in docs/agents/e2e-testing.md, is corrected here from three to seven. Nothing pins it, so it will drift again.
  • The node walk iterates for node in ${nodes} unquoted, so pathname expansion applies alongside word splitting. Node names are RFC1123 so it cannot bite today; while IFS= read -r node would make that structural instead of incidental.

Screenshots

Downstream repositories

Release note

test(e2e): capture tenant worker CPU throttling counters and their CFS ceiling when a node-join fails, so a worker held at its own limit can be told apart from one losing host CPU

Summary by CodeRabbit

  • New Features

    • Added worker CPU-throttling diagnostics to Kubernetes troubleshooting reports.
    • Reports now identify CPU quota gaps, partial reads, filtering issues, and incomplete results.
    • Added safeguards for bounded node traversal and configurable collection budgets.
  • Bug Fixes

    • Improved failure handling when diagnostic commands, timeouts, or metric reads are unavailable.
  • Tests

    • Expanded coverage for CPU-throttling capture, node-join diagnostics, timeout behavior, configuration validation, and partial failures.
  • Documentation

    • Updated diagnostic collector counts and collection-order documentation.

@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 10, 2026
@coderabbitai

coderabbitai Bot commented Aug 10, 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: 41fc9e4b-fc19-4f34-a8b8-93109a94091e

📥 Commits

Reviewing files that changed from the base of the PR and between 641efd3 and b8a5b9b.

📒 Files selected for processing (1)
  • docs/agents/e2e-testing.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/agents/e2e-testing.md

📝 Walkthrough

Walkthrough

The PR adds tenant worker CPU-throttling capture to Kubernetes failure diagnostics. It records cAdvisor CPU metrics and failure states, limits node traversal, integrates phase-budget handling, and adds extensive BATS coverage.

Changes

Tenant worker CPU throttling diagnostics

Layer / File(s) Summary
CPU collector and diagnostic integration
hack/e2e-chainsaw/_lib/run-kubernetes.sh, hack/e2e-chainsaw/kubernetes-*/chainsaw-test.yaml, docs/agents/e2e-testing.md
Adds tenant worker CPU CFS and quota collection with bounded node reads, status metadata, diagnostic ordering updates, and corrected BATS source-count documentation.
Collector behavior validation
hack/run-kubernetes-cpu-throttle_test.bats
Tests node discovery, metric filtering, quota detection, read budgets, node caps, warnings, and artifact output.
Failure and fallback validation
hack/run-kubernetes-cpu-throttle_test.bats
Tests failed, partial, timed-out, killed, filtered, and no-timeout collection paths.
Node-join diagnostic integration tests
hack/run-kubernetes-node-join_test.bats
Updates report-directory setup, collector stubs, phase-budget checks, timeout tests, and wall-clock audits.

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

Sequence Diagram(s)

sequenceDiagram
  participant NodeJoinFailureReporter
  participant CPUCollector as cozy_capture_tenant_worker_cpu_throttle
  participant Kubectl
  participant Cadvisor as kubelet cAdvisor metrics
  NodeJoinFailureReporter->>CPUCollector: invoke failure diagnostic
  CPUCollector->>Kubectl: list tenant worker Pods and nodes
  CPUCollector->>Cadvisor: read node CPU CFS metrics
  Cadvisor-->>CPUCollector: return metrics or read status
  CPUCollector-->>NodeJoinFailureReporter: write per-node artifacts
Loading

Possibly related PRs

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.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: capturing worker CPU throttling counters during node-join failure diagnostics.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/e2e-capture-worker-cpu-throttling

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.

@lexfrei
Aleksei Sviridkin (lexfrei) marked this pull request as ready for review August 10, 2026 06:35

@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: 2

🤖 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 `@docs/agents/e2e-testing.md`:
- Line 47: Update the run-kubernetes sourcing statement in the e2e-testing
documentation to use the accurate count and explicitly define the scope as
top-level hack/*.bats files or all hack/**/*.bats files. Ensure the wording
matches the glob scope being counted and does not imply subdirectory files are
included unless they are.

In `@hack/run-kubernetes-cpu-throttle_test.bats`:
- Around line 1168-1179: Update the staged-binary validation in the
stripped-PATH test to verify every command listed in the staging loop—mkdir,
sort, grep, mv, rm, mktemp, wc, tr, and cat—not just grep. Reuse the existing
missing-binary failure behavior so the collector and assertion at line 1203 run
only when the complete required command set is available.
🪄 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: 6f6eec2c-0354-4d17-b3bf-b7ef06a168cc

📥 Commits

Reviewing files that changed from the base of the PR and between 677d80a and 641efd3.

📒 Files selected for processing (6)
  • docs/agents/e2e-testing.md
  • 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-cpu-throttle_test.bats
  • hack/run-kubernetes-node-join_test.bats

- Chainsaw deletes the resources it `apply`-ed during its cleanup phase (bounded by the `delete`/`cleanup` timeouts in `hack/e2e-chainsaw/.chainsaw.yaml`). Do not hand-roll teardown for resources Chainsaw created.
- A self-contained `trap '… ' EXIT` **inside a single `script` step** — to kill a port-forward or remove a temp dir — is fine, because it runs in a contained subprocess with its variables in scope. See `hack/e2e-chainsaw/bucket/chainsaw-test.yaml`. What is banned is test-level trap-based cleanup of the BATS kind. The same carve-out holds inside a BATS `@test` when the trap sits in an explicit subshell — `( … trap "kill $pid" EXIT … )` — because a subshell trap does not replace the one the `bats` binary installs, so a failure inside it still prints its `not ok`. `hack/e2e-test-openapi.bats` relies on this to kill a backgrounded `kubectl proxy`; moving that cleanup to the end of the body would leak a process holding a fixed port rather than fix anything.
- The ban extends to every BATS file under `hack/`, subdirectories and the `e2e-` prefixed ones included, for a second reason worth knowing before you debug one: an `EXIT` trap inside an `@test` body replaces the one the `bats` binary installs for its own bookkeeping, and a test that then **fails** prints no TAP line at all. It does not appear as `not ok`; it disappears, and the run ends with `# bats warning: Executed N instead of expected M tests` and a non-zero exit. Verified with Bats 1.14.0. Anyone reading the tail of the output, or grepping it for `not ok`, sees a green suite — and the CI runner `hack/cozytest.sh`, which is not the `bats` binary, reports the same failure correctly, so the two disagree exactly when it matters. Clean up at the end of the test body instead: both runners set `-e`, so the cleanup is unreachable on failure and the scratch directory is left behind for inspection, which is what you want from a failed test anyway. When a suite reports zero failures, confirm it also reports how many tests it ran: an exit code answers "did anything fail", never "did anything run". `hack/bats-no-exit-trap.bats` enforces this across every `hack/**/*.bats`, subdirectories included: a file that carries no `# EXIT-TRAP DEBT: N` comment must contain no EXIT-trap line at all, and a file that carries one must install exactly N — so a trap appearing or disappearing fails until the file's own number is corrected. Note what that does and does not buy: it is a ratchet on the number's *accuracy*, not on the debt itself, because adding a trap and raising `N` in the same change is green. Nothing mechanical stops the count growing — review does, which is the point of the number living in the file being reviewed. A trap inside an explicit subshell is exempt from the ban but still counted, so a declaration is not by itself an admission of debt; `hack/e2e-test-openapi.bats` is the current example and says so in its own header. The scan reads `.bats` files only, so a handler reaching a test body from a sourced `.sh` is outside it — `hack/e2e-chainsaw/_lib/run-kubernetes.sh` installs two, each benign for its own reason rather than by design: the one in `cozy_capture_tenant_talos` because that function is declared with `(` and runs in a subshell, and the one in `run_kubernetes_test` because no `@test` calls it despite being declared with `{`. Three `hack/*.bats` source that library. Treat the guard as a ratchet over a common spelling, not as proof that a file installs no handler. A handful of files still declare a debt while their conversion waits on the branches that own them; when one of those reports zero failures, reconcile its `1..N` plan against its `ok` count before believing the run.
- The ban extends to every BATS file under `hack/`, subdirectories and the `e2e-` prefixed ones included, for a second reason worth knowing before you debug one: an `EXIT` trap inside an `@test` body replaces the one the `bats` binary installs for its own bookkeeping, and a test that then **fails** prints no TAP line at all. It does not appear as `not ok`; it disappears, and the run ends with `# bats warning: Executed N instead of expected M tests` and a non-zero exit. Verified with Bats 1.14.0. Anyone reading the tail of the output, or grepping it for `not ok`, sees a green suite — and the CI runner `hack/cozytest.sh`, which is not the `bats` binary, reports the same failure correctly, so the two disagree exactly when it matters. Clean up at the end of the test body instead: both runners set `-e`, so the cleanup is unreachable on failure and the scratch directory is left behind for inspection, which is what you want from a failed test anyway. When a suite reports zero failures, confirm it also reports how many tests it ran: an exit code answers "did anything fail", never "did anything run". `hack/bats-no-exit-trap.bats` enforces this across every `hack/**/*.bats`, subdirectories included: a file that carries no `# EXIT-TRAP DEBT: N` comment must contain no EXIT-trap line at all, and a file that carries one must install exactly N — so a trap appearing or disappearing fails until the file's own number is corrected. Note what that does and does not buy: it is a ratchet on the number's *accuracy*, not on the debt itself, because adding a trap and raising `N` in the same change is green. Nothing mechanical stops the count growing — review does, which is the point of the number living in the file being reviewed. A trap inside an explicit subshell is exempt from the ban but still counted, so a declaration is not by itself an admission of debt; `hack/e2e-test-openapi.bats` is the current example and says so in its own header. The scan reads `.bats` files only, so a handler reaching a test body from a sourced `.sh` is outside it — `hack/e2e-chainsaw/_lib/run-kubernetes.sh` installs two, each benign for its own reason rather than by design: the one in `cozy_capture_tenant_talos` because that function is declared with `(` and runs in a subshell, and the one in `run_kubernetes_test` because no `@test` calls it despite being declared with `{`. Seven `hack/*.bats` source that library. Treat the guard as a ratchet over a common spelling, not as proof that a file installs no handler. A handful of files still declare a debt while their conversion waits on the branches that own them; when one of those reports zero failures, reconcile its `1..N` plan against its `ok` count before believing the run.

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Count the bats files that source run-kubernetes.sh.
set -euo pipefail

echo "=== top-level hack/*.bats sourcing run-kubernetes.sh ==="
fd -t f -e bats . hack --max-depth 1 --exec sh -c '
  grep -l "run-kubernetes\.sh" "$1" 2>/dev/null || true
' _ {} \; | sort -u | tee /tmp/top.txt | wc -l

echo "=== any depth hack/**/*.bats sourcing run-kubernetes.sh ==="
fd -t f -e bats . hack --exec sh -c '
  grep -l "run-kubernetes\.sh" "$1" 2>/dev/null || true
' _ {} \; | sort -u | tee /tmp/all.txt | wc -l

echo "=== files ==="
cat /tmp/all.txt

Repository: cozystack/cozystack

Length of output: 257


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== top-level hack files ==="
find hack -maxdepth 1 -type f -name '*.bats' 2>/dev/null | sort

echo "=== all hack bats files sourcing run-kubernetes.sh ==="
find hack -type f -name '*.bats' 2>/dev/null | sort | while IFS= read -r f; do
  if grep -q -- 'run-kubernetes\.sh' "$f"; then
    echo "$f"
  fi
done

echo "=== exact count top-level ==="
find hack -maxdepth 1 -type f -name '*.bats' 2>/dev/null | sort | while IFS= read -r f; do
  grep -q -- 'run-kubernetes\.sh' "$f"
  echo $?
done | grep -x '^0$' | wc -l

echo "=== exact count all depths ==="
find hack -type f -name '*.bats' 2_AR

Repository: cozystack/cozystack

Length of output: 2641


🏁 Script executed:

#!/bin/sh
set -uu

echo "=== top-level hack/*.bats sourcing run-kubernetes.sh ==="
find hack -maxdepth 1 -type f -name '*.bats' -print | sort | while IFS= read -r f; do
  if grep -q -- 'run-kubernetes\.sh' "$f"; then
    echo "$f"
  fi
done

echo "=== hash total top-level count ==="
find hack -maxdepth 1 -type f -name '*.bats' -print | sort | while IFS= read -r f; do
  grep -q -- 'run-kubernetes\.sh' "$f"
done | grep -c '^0$' || true

echo "=== hash total all depths count ==="
find hack -type f -name '*.bats' -print | sort | while IFS= read -r f; do
  grep -q -- 'run-kubernetes\.sh' "$f"
done | grep -c '^0$' || true

echo "=== occurrences/context ==="
find hack -type f -name '*.bats' -print | sort | while IFS= read -r f; do
  if grep -q -- 'run-kubernetes\.sh' "$f"; then
    printf '%s\n' "$f"
    grep -n -- 'run-kubernetes\.sh' "$f"
  fi
done

Repository: cozystack/cozystack

Length of output: 10053


Update the run-kubernetes source-line count.

docs/agents/e2e-testing.md says “Seven hack/*.bats source that library”, but there are more. The glob hack/*.bats also excludes subdirectories, so make the sentence explicit about whether it means top-level files only or all hack/**/*.bats files.

🤖 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 `@docs/agents/e2e-testing.md` at line 47, Update the run-kubernetes sourcing
statement in the e2e-testing documentation to use the accurate count and
explicitly define the scope as top-level hack/*.bats files or all hack/**/*.bats
files. Ensure the wording matches the glob scope being counted and does not
imply subdirectory files are included unless they are.

Comment on lines +1168 to +1179
for c in mkdir sort grep mv rm mktemp wc tr cat; do
for d in /bin /usr/bin /usr/local/bin /opt/homebrew/bin; do
if [ -x "$d/$c" ]; then
ln -sf "$d/$c" "$tmp/bin/$c"
break
fi
done
done
if [ ! -x "$tmp/bin/grep" ]; then
echo "could not stage a PATH without timeout" >&2
return 1
fi

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

Check every staged binary, not only grep.

The first stripped-PATH test verifies seven binaries at lines 1116-1121 and explains why: a missing one fails the test for the wrong reason. This test stages the same list at line 1168 but verifies only grep. The collector also calls mkdir, sort, mv, rm, mktemp, wc and tr on this path. If one of them is absent from the staged PATH, the capture misbehaves and the assertion at line 1203 can still pass.

🛡️ Proposed fix to verify the full staged set
-  if [ ! -x "$tmp/bin/grep" ]; then
-    echo "could not stage a PATH without timeout" >&2
-    return 1
-  fi
+  for c in mkdir sort grep mv rm mktemp wc tr; do
+    if [ ! -x "$tmp/bin/$c" ]; then
+      echo "FAIL: could not stage $c in the stripped PATH; the check below would be vacuous" >&2
+      return 1
+    fi
+  done
+  if [ -e "$tmp/bin/timeout" ]; then
+    echo "FAIL: timeout leaked into the stripped PATH; this test would prove nothing" >&2
+    return 1
+  fi
🤖 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/run-kubernetes-cpu-throttle_test.bats` around lines 1168 - 1179, Update
the staged-binary validation in the stripped-PATH test to verify every command
listed in the staging loop—mkdir, sort, grep, mv, rm, mktemp, wc, tr, and
cat—not just grep. Reuse the existing missing-binary failure behavior so the
collector and assertion at line 1203 run only when the complete required command
set is available.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overview

This adds cozy_capture_tenant_worker_cpu_throttle() to hack/e2e-chainsaw/_lib/run-kubernetes.sh, plus wiring into cozy_report_node_join_failure(), two one-line chainsaw-test.yaml comment updates, a one-line doc count fix, and a large new BATS suite (hack/run-kubernetes-cpu-throttle_test.bats, 1453 lines) plus updates to hack/run-kubernetes-node-join_test.bats.

On the large diff: verified it is not vendored/generated. run-kubernetes.sh gets +450/-9 of hand-written shell (collector logic + heavy inline rationale comments); the new .bats file is +1453 hand-written test lines with real mocks (kubectl, timeout) and multiple failure-mode fixtures; run-kubernetes-node-join_test.bats gets +135/-9 to add use_temp_report_dir and audit assertions. This is proportionate for a diagnostics collector this defensive plus its regression coverage — appropriate, not padding.

Runs only on failure, cannot fail the job: traced the call chain — cozy_report_node_join_failure is invoked only from the if ! timeout 18m ... node-Ready-deadline failure branch in run_kubernetes_test (never on the happy path), and the new collector is invoked as cozy_capture_tenant_worker_cpu_throttle || true, gated by cozy_diag_phase_has_time '(d) tenant worker CPU throttling'. It cannot slow passing runs and cannot itself fail the suite.

Robustness of the capture: bounded to at most 4 reads (1 Pod listing + up to max_nodes=3 per-node kubectl get --raw .../metrics/cadvisor), each wrapped in the existing COZY_DIAG_READ_TIMEOUT/COZY_DIAG_READ_GRACE machinery via the same _cozy_diag_seconds re-validation pattern used elsewhere in the file (verified signature match against other call sites in run-kubernetes.sh). Every command whose failure matters is captured via || var=$? / || true rather than relying on set -e, so a failed sub-step can't silently abort the function mid-write. The metric regex (^container_(cpu_cfs_(periods_total|throttled_periods_total|throttled_seconds_total)|spec_cpu_(period|quota))\{) is anchored to avoid partial-name matches, and the three-stage grep filter (metric name -> namespace -> pod-name prefix) deliberately keeps exit statuses separable to distinguish "kubelet never answered" from "kubelet answered, no matching series" from "local read-back failed" — verified this distinction is both implemented and covered by dedicated tests (killed read, timeout off PATH, tenant-control-plane exclusion).

Doc accuracy check: independently counted the hack/*.bats files that actually . hack/e2e-chainsaw/_lib/run-kubernetes.sh (not just mention the path in a comment) at the PR's head commit — confirmed exactly 7. The docs/agents/e2e-testing.md edit from "Three" to "Seven" is factually correct.

Ordering trade-off: the collector is placed ahead of the guest serial-console capture in the failure-report sequence. This is explicitly reasoned about in both the PR description and inline comments (this collector's answer has no other source in the tree; console evidence is irreplaceable but only starved if the budget actually runs out, and this collector's own ceiling is ~100s of a 480s budget). Reasonable and disclosed, not hidden.

Existing review thread status: CodeRabbit posted 2 actionable comments on the current head (no follow-up commits since). Neither is addressed yet; see non-blocking notes below — neither rises to a regression.

No confirmed regression, no crash/mask-the-real-failure risk, no teardown/happy-path impact found. Approving with notes — the two open CodeRabbit items are worth a quick follow-up but aren't blocking.

Non-blocking / nits

  1. Unaddressed CodeRabbit finding — incomplete PATH-staging assertion: in hack/run-kubernetes-cpu-throttle_test.bats, the test "a killed read is not written up as a grace period that never ran" stages 9 binaries into a stripped $tmp/bin but only asserts that grep was staged successfully before running the subprocess. Its sibling test immediately above checks 8 of the 9 staged binaries. If any of the other 8 failed to resolve on some runner, this test could pass or fail for the wrong reason instead of testing what it claims to. Low real-world risk (these are universal core utils on any Linux/macOS CI box), but worth tightening for consistency with the sibling test.
  2. Unaddressed CodeRabbit finding — doc wording scope: the docs/agents/e2e-testing.md line "Seven hack/*.bats source that library" is numerically correct for the literal hack/*.bats (top-level only) glob, but doesn't explicitly say whether the count would change under hack/**/*.bats. A one-clause clarification would remove the ambiguity CodeRabbit flagged.
  3. Several limitations are already self-disclosed in the PR body (unchecked mkdir -p, unquoted for node in ${nodes} word-splitting, conservative whole-file "uncapped" note, stale bats-file count risk) — all pre-existing patterns shared with neighboring collectors in the same file, not new debt introduced here, and correctly called out rather than hidden.

Closing

Solid, clearly-scoped diagnostics addition: failure-path-only, budget-gated, non-fatal by construction, and unusually well tested for its size. Recommend landing after a look at the two CodeRabbit items (optional, non-blocking).

When a tenant worker misses the node-Ready deadline, the sandbox node
it runs on is routinely at half its capacity with a clean kernel log.
In that state "the host starved the VM" and "the VM was held at its
own CFS ceiling" produce identical evidence: node-level utilisation
reports what the node used, never what the container was allowed. The
CFS counters are the only place the difference is recorded, and
nothing in the tree collected them for the tenant workers, so every
diagnosis of this failure so far had to be inferred from outside.

Read them from the kubelet's cAdvisor endpoint on the node hosting
each worker. Five series carry the whole answer and cAdvisor publishes
all of them per container, so nothing is computed here: the scheduled
and throttled period counts, the time spent throttled, and the quota
and period that form the ceiling. The throttled counters alone say a
container hit some ceiling, not which one, and a VM capped at one core
and a VM capped at eight are the same number without the quota.

Absence of the quota series is itself a reading rather than a gap.
cAdvisor emits it only when the quota is non-zero, so a container
reporting the period and no quota is one running uncapped - which is
one of the two answers this collector exists to distinguish, arriving
without being derived.

Reading the kubelet rather than the container matters beyond
convenience. A reader running inside the container shares the cgroup
it measures, so it slows down in proportion to how much the answer
matters and would need a budget sized for the pathological case; the
kubelet is outside that cgroup. It also drops any dependency on what
the container image ships and on how the host lays cgroups out.

The namespace is not a worker filter and is not used as one: it also
holds the tenant control plane, whose apiserver and etcd carry CPU
limits of their own and can be genuinely throttled. The Pod name
carries the second half of the filter, so a throttled apiserver cannot
answer under a heading that says worker.

The walk is over nodes rather than Pods, because the endpoint is per
node and one read covers every worker it hosts. It is gated on the
diagnostics phase budget, and placed first among the five gated
collectors: the budget declines whatever has not started when it runs
out, its own ceiling is four bounded reads rather than the minutes the
four after it can spend, and it is the only one whose question has no
other answer in the tree. That order is a trade, stated here because
it is a decision rather than a detail - it puts the guest serial
console one place further from the budget, and that capture is the
only one describing a worker which never reached apid.

Both reads take the block's own read-budget knob rather than a literal
equal to it, so lowering that knob lowers these too. Each keeps its own
exit status rather than routing through the shared helper, which always
returns zero by design: this collector has to tell "the kubelet was not
read" from "the kubelet answered and carried no worker", and only the
status separates them. The stream is captured whole and filtered after,
since a pipeline reports its last command's status and would otherwise
hand that decision to grep.

Every way a read can come up short is named rather than left as zero
bytes: a kubelet that was not read, one that answered with nothing for
the namespace, a stream cut short, and exit 124 - which cannot
distinguish this collector timing out from the read exiting 124 on its
own - each get their own line, and each says whether an error log
exists to explain it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
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