Skip to content

feat(doctor): surface the machine beneath the nodes (backend#2221) - #541

Draft
shujaatTracebloc wants to merge 3 commits into
developfrom
feature/2221-doctor-machine-chain
Draft

feat(doctor): surface the machine beneath the nodes (backend#2221)#541
shujaatTracebloc wants to merge 3 commits into
developfrom
feature/2221-doctor-machine-chain

Conversation

@shujaatTracebloc

@shujaatTracebloc shujaatTracebloc commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

Adds a Machine capacity check to tracebloc doctor: the four-level chain host → Docker VM → node capacity → unrequested, plus the one invariant that makes the double-count legible:

sum(node capacity) ≤ VM capacity

Uncapped k3d violates it by exactly the node count. This is the doctor half of backend#2221 (RFC-BACKEND-664 §P4) and touches no installer, so it cannot conflict with the client-side work on the same ticket.

Why

Every other check in this package reads the cluster and believes it. On a local k3d install that belief is misplaced — the node containers are created with NanoCpus=0 CpuQuota=0 Memory=0, so each one honestly reports the whole Docker VM. Measured on k3d v5.9.0 / k3s v1.35.5 / Docker 29.5.2, macOS aarch64:

Layer CPU Memory
Host 10 16.00 GiB
Docker Desktop VM 10 7.75 GiB
k3d node containers ×2 10 each 8126672Ki each — byte-identical to the VM's MemTotal
What Kubernetes believes 20 15.50 GiB (2.000×)

So checkNodeFit can truthfully report "a Ready node can schedule a training job" for two jobs that cannot both exist, and its drift nudge (this machine could give a run up to cpu=9,memory=4Gi) advertises half the VM twice. The gap between the levels is the customer's problem, and nothing surfaced it: doctor read level 3 only, the installer's preflight gates on level 1, and nothing looked at level 2 at all.

Verified on a live cluster, not just fakes

Run against a real 2-node edge:

· FAIL Node capacity — no Ready node can fit a training job (needs cpu=2, memory=8Gi)
· WARN Machine capacity — host 16.00 GiB → Docker VM 7.75 GiB (10 cpu)
       → 2 nodes claiming 15.50 GiB → 4.55 GiB unrequested — Kubernetes believes
       2.00× the memory this machine has, because the k3d node containers are
       uncapped and each reports the whole VM

The two lines together tell the true story that neither tells alone. As a cross-check, 7.75 − 4.55 = 3.20 GiB requested matches the ~3276 Mi the control plane is known to request.

The live run found a bug in this check that the fakes could not. "Unrequested" was computed from the inflated sum, so it reported 12.30 GiB free on a 7.75 GiB VM — the fourth level inheriting the third's error, this check repeating the very lie it exists to expose. Now measured against min(sum, VM), which is a no-op on an honest cluster. There is a named regression test for it.

Refuses to assert what it cannot know

StatusUnknown (no signal — never moves the verdict or exit code) in four cases:

  • non-k3d or mixed cluster — on EKS the nodes are machines and docker info on this laptop describes something unrelated; asserting from it would be worse than silence
  • unreadable VM — the VM is the level this check adds; guessing is what the bug already does
  • no Ready node
  • nodes reporting no capacity — "0 GiB claimed, all good" would be a green with nothing behind it

The host level is best-effort and omitted rather than zero-filled when unreadable: the VM is the constraint, the host is context.

The tolerance is measured, not picked

A node capped at 3 GiB (3221225472 B) reported capacity 3221225Ki2.4% above the limit it came from. A strict sum > vm would therefore report correct capping as over-commit. 1.05 sits far below the 2.00× the real bug produces and far above that rounding. There is a test for each side.

Uses capacity, not allocatable, deliberately: the invariant is about what the nodes claim the machine is. Making allocatable honest is P3's kubelet-reservation question, and k3s sets no reservations, so on the clusters this applies to the two are byte-identical anyway (confirmed while measuring).

Tests

internal/doctor/machine_test.go, 18 cases pinned to the measured numbers. Probes are injectable (Options.VMProbe / HostProbe) so the chain is fully exercisable without Docker.

Mutation-tested — each of these reddens its own tests and nothing else:

mutation reddens
tolerance 1.05100 the 2-node and 3-node over-count tests
allK3d always true the non-k3d and mixed-cluster tests
count terminal pods as holding memory the unrequested-level test

Full suite green. gofmt -s, goimports -local, staticcheck -checks all,-ST1005, ineffassign, misspell all clean locally.

TestRun_HealthyCluster's check count moves 9 → 10. Its fixture nodes are not k3d-named, so the new check reports StatusUnknown there — which the rollup ignores, so the healthy verdict is unaffected. That is the intended behaviour on a non-local cluster, and the comment says so rather than leaving it looking like a fixture accident.

20 new strings are catalogued in zz-all-strings.golden (regenerated, diff reviewed).

Scope

Deliberately only the surfacing half. Capping the node containers and collapsing the topology is the client-installer half — it changes the default topology on every install, so it is flagged for @LukasWodka rather than landing on review alone. The contract arithmetic it will read is tracebloc/client-runtime#363.

Audit of the ticket's claims, including two corrections that changed the fix, is on backend#2221.

🤖 Generated with Claude Code


Note

Medium Risk
Diagnostic-only doctor check that shells out to docker info/sysctl and lists cluster-wide pods/nodes. It can change doctor output and warn on default local installs, but does not alter scheduling, auth, or installer topology.

Overview
Adds a Machine capacity check to tracebloc doctor so local k3d installs show the real host → Docker VM → node capacity → unrequested chain, instead of trusting Kubernetes’ inflated node memory.

Uncapped multi-node k3d reports each node as the whole VM, so checkNodeFit can say a job fits when two such jobs would OOM the VM. The new check warns when sum(node capacity) exceeds VM size (with a 5% tolerance for k3d’s measured rounding) and remedies with single-node or --servers-memory/--agents-memory. “Unrequested” is bounded by min(sum, VM) so free memory is not computed from the lie.

It stays unknown (no verdict/exit-code impact) for non-k3d, mixed, remote k3d, missing VM/docker info, or no Ready capacity. Host RAM is best-effort and omitted if unreadable. Probes are injectable and time-bounded so a wedged Docker daemon cannot hang the sweep.

Reviewed by Cursor Bugbot for commit 6ebe9ae. Bugbot is set up for automated code reviews on this repo. Configure here.

Every other check in this package reads the cluster and believes it. On a local
k3d install that belief is misplaced: the node containers are created with
`NanoCpus=0 CpuQuota=0 Memory=0`, so each one honestly reports the WHOLE Docker
VM and a default server+agent cluster tells Kubernetes the machine is twice its
real size. Measured on k3d v5.9.0 / k3s v1.35.5 / Docker 29.5.2:

  host              10 cpu / 16.00 GiB
  Docker VM         10 cpu /  7.75 GiB   <- the real machine
  2 node containers          15.50 GiB   <- what Kubernetes believes

The node memory was byte-identical to the VM's MemTotal on BOTH nodes. So
checkNodeFit can truthfully say "a Ready node can schedule this job" for two
jobs that cannot both exist, and its drift nudge advertises half the VM twice.

Adds "Machine capacity": host -> VM -> node capacity -> unrequested, plus the
one invariant that makes the lie legible, sum(node capacity) <= VM capacity.
Uncapped k3d violates it by exactly the node count.

Verified against a LIVE 2-node edge, not just fakes:

  WARN Machine capacity -- host 16.00 GiB -> Docker VM 7.75 GiB (10 cpu)
       -> 2 nodes claiming 15.50 GiB -> 4.55 GiB unrequested -- Kubernetes
       believes 2.00x the memory this machine has [...]

That live run also found a bug in this check that fakes could not: the
"unrequested" level was computed from the inflated SUM, so it reported 12.30 GiB
free on a 7.75 GiB VM -- the fourth level inheriting the third's error, this
check repeating the very lie it exists to expose. Now measured against
min(sum, VM), a no-op on an honest cluster. Regression-tested.

Refuses to assert what it cannot know, in four ways: a non-k3d or mixed cluster
gets StatusUnknown (on EKS the nodes ARE machines and `docker info` on this
laptop describes something unrelated); so does an unreadable VM, no Ready node,
and nodes reporting no capacity at all -- "0 GiB claimed, all good" would be a
green with nothing behind it. StatusUnknown carries no signal, so none of these
move the verdict or the exit code.

The tolerance is measured too. A node capped at 3 GiB (3221225472 B) reported
capacity 3221225Ki, 2.4% ABOVE the limit it came from, so a strict `sum > vm`
would call correct capping an over-commit. 1.05 is far below the 2.00x the real
bug produces and far above that rounding.

Probes are injectable (Options.VMProbe / HostProbe), so the chain is fully
exercisable without Docker. The host level is best-effort and OMITTED rather
than zero-filled when unreadable -- the VM is the constraint, the host is
context.

Mutation-tested: widening the tolerance, dropping the k3d guard, and counting
terminal pods as holding memory each redden their own tests.

Does not touch the installers, so it cannot conflict with the client-side work
on this ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shujaatTracebloc shujaatTracebloc self-assigned this Aug 20, 2026
@shujaatTracebloc

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread internal/doctor/machine.go Outdated
Comment thread internal/doctor/machine.go
shujaat hasan and others added 2 commits August 20, 2026 16:53
The tolerance comment said node capacity came from "the cgroup limit with a
rounding that does not exactly invert". That is wrong about the mechanism, and
the mechanism matters for the rest of this ticket. Measured:

  k3d --servers-memory 3g  ->  cgroup memory.max = 3221225472
  k3d's fake /proc/meminfo ->  MemTotal: 3221225 kB
  kubelet capacity         ->  3221225Ki  == 3298534400 B, +2.4%

k3d caps a node by bind-mounting a SYNTHETIC /proc/meminfo into the node
container (a "fakeowner" mount), not by the cgroup -- kubelet never reads the
cgroup for capacity. k3d writes MemTotal as bytes/1000 labelled kB, but kB
there means 1024 bytes, so advertised capacity overstates the real cgroup limit
by 2.4%. The tolerance was right; the reason recorded beside it was not.

Two consequences worth having written down, both measured:

  * capping is a CREATE-TIME operation. `docker update --memory=4g` on a
    running node container set memory.max=4294967296 and left /proc/meminfo at
    the VM's 8126672 kB, so capacity stayed 8126672Ki even after a restart.
    Existing edges cannot be capped in place; they have to be recreated.
  * a pod sized to the advertised capacity can exceed the node's real cgroup
    limit by 2.4%. The 3 GiB platform overhead absorbs that at any realistic
    cap, but it is a reason not to shrink the overhead casually.

Comment-only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r (Bugbot #541)

Two real defects from Bugbot, both reproduced with a failing test first.

1. The probes could hang the sweep. hostProbe ran `sysctl` through a bare
   exec.Command with no context at all, and dockerVMProbe took the signal ctx
   with no deadline of its own -- unlike httpProbeTimeout (8s) on the other
   injectable probe in this package. The sharp edge is that the remedy for an
   unreadable VM is `docker info`, which is exactly what hangs against a WEDGED
   daemon as opposed to a stopped one, so the probe for that case could hang
   `tracebloc doctor` with no verdict and no exit code. Both are now bounded by
   machineProbeTimeout, mirroring httpProbeTimeout; the installer's own
   _docker_answers bounds `docker info` for the same reason. HostProbe takes a
   context now, which is also the honest signature for something that shells
   out.

2. k3d node NAMES are not proof the cluster is on this machine. A kubeconfig
   pointing at another host's k3d cluster -- a LAN address, an SSH tunnel, a
   copied config -- has the same k3d-* names while `docker info` here describes
   an unrelated VM. That is the same error the EKS guard already refuses, seen
   from a direction I missed. Now a PRESENT, non-loopback API endpoint is a
   disqualifier and the check returns StatusUnknown, reusing the isLoopback
   this package already had.

   An EMPTY ServerURL deliberately still measures. It is documented as optional
   throughout this package, so treating absence as "remote" would let a missing
   Option silence a check that has node-name evidence to go on. Pinned by its
   own test, and mutation-testing confirms tightening the guard to
   `!isLoopback(serverURL)` reddens exactly that test.

Re-verified on the live 2-node edge afterwards: served on loopback, so it is
still measured and still reports 2.00x.

Mutation-tested: dropping the remote guard reddens the remote test and nothing
else; tightening it to reject an empty URL reddens the empty-URL test.

One new string catalogued in zz-all-strings.golden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shujaatTracebloc

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6ebe9ae. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant