Skip to content

Next - #40

Open
ardaakman wants to merge 31 commits into
mainfrom
next
Open

Next#40
ardaakman wants to merge 31 commits into
mainfrom
next

Conversation

@ardaakman

@ardaakman ardaakman commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

What changed

Why

Checks

  • make check
  • make test-local
  • Chart values changed? Doc comment, schema entry, and a default that is
    safe on a cluster that is not ours.
  • Nothing here only makes sense in one particular cluster.

Note

High Risk
Changes privileged daemon host enrollment/repair logic and operator schedulable capacity; misbehavior could strand nodes or mis-advertise GPUs until rolled back.

Overview
This PR extends the node daemon and operator, bumps thunder-sdk to v0.2.0, and refreshes chart defaults and integration tests.

Helm / daemon configuration: Adds daemon.extraEnv so operators can inject extra container env vars (including secrets via valueFrom). Variables named THUNDERD_ENV_<key> are merged into the host’s /etc/thunder/thunderd.env once at daemon startup (atomic write via stdin, no thunderd restart). Chart schema, DaemonSet template, values, and README document rollout behavior (pod restart required for env/secret changes).

Daemon repair behavior: Reworks unhealthy-node handling so enrolled nodes are never re-enrolled (avoids duplicate hosts and enrollment-token spam). Repairs go through a capped budget (repairAttempt: restart, CLI reinstall without token, dangling thunderd.service symlink sweep, optional thunder update / rollback when distribution checksum changes). Enrollment is reserved for nodes with no auth token on disk.

Operator capacity: Hosts with configurations.cordoned no longer contribute physical GPU capacity; live client counts still set the advertised floor when every host is cordoned. Local kind tests assert cordon/uncordon ResourceSlice and DeviceClass behavior.

Misc: publish-images workflow gains pull-requests: write; kind_bridge_address is more reliable for the registry stub; operator/daemon image tags updated in values.

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

Arda Akman and others added 8 commits August 20, 2026 21:39
…e74a397d24b60916fda3576e77d85e1e3b3e3

chore: pin images for 077e74a
The jq filter defaulted a missing Gateway to "" inside select() but then
printed .Gateway itself, so an IPAM entry with no gateway passed the IPv4
test and jq -r emitted "null". The caller's guard only rejected an empty
value, so the stub was told to listen on "null:0" and died resolving it as
a hostname:

  listen on null:0: listen tcp: lookup null on 127.0.0.53:53: server misbehaving

Docker on the CI runner records the kind network that way; locally both
IPAM entries carry gateways, which is why this only failed in Actions.

Drop gatewayless entries with `.Gateway // empty`, fall back to the
default route the control-plane container actually uses, and return
nothing unless the result parses as an IPv4 address.

Claude-Session: https://claude.ai/code/session_01BBxWEqjEe4vNe62EAEnGzy
go.mod pointed at v0.1.1-0.20260820213702-4e56982dc1a7, the pseudo-version
for commit 4e56982. That commit is what v0.2.0 tags, so this is the same
code under its released name rather than a dependency change.

Claude-Session: https://claude.ai/code/session_01BBxWEqjEe4vNe62EAEnGzy

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Bridge lookup aborts past check
    • Added || true to kind_bridge_address call to prevent errexit from aborting before the empty-string check and diagnostic message can run.

Create PR

You can send follow-ups to the cloud agent here.

Comment thread hack/lib/thunder.sh Resolved
cursoragent and others added 9 commits August 21, 2026 23:36
When kind_bridge_address returns non-zero, prevent errexit from aborting
the script before the empty-string check can run and display the intended
diagnostic message about IPAM and control-plane fallbacks.
An unhealthy enrolled node was falling through to enroll() after a
couple of failed restarts, and every unreadable-CLI status (exit 127)
was treated as unenrolled -- each occurrence minted a fresh single-use
enrollment token and created a duplicate host row in Central. This is
what spammed the dashboard in the 2026-08-24 incident.

- ensureEnrolled now determines "is this node enrolled" once: trust
  thunder status's own AuthTokenConfigured field when it has an answer,
  otherwise fall back to reading THUNDERD_AUTH_TOKEN directly from the
  host's /etc/thunder/thunderd.env through the daemon's read-only /host
  mount (nodeEnrolled/hostAuthTokenConfigured). An unreadable status or
  env file is never mistaken for "unenrolled".
- While enrolled, the only repairs are a token-less restart, or (when
  `thunder status` itself won't run) a tokenless CLI reinstall via
  install.sh's anonymous binary-only mode. Neither ever escalates to
  enroll().
- A durable per-outage repair budget (repair_budget.go) caps repair
  attempts at 5, persisted on the host so a pod restart mid-outage
  doesn't re-arm it; past the cap the daemon logs once and cools off
  for an hour, trying only a restart, never an enroll. The budget
  clears on the next healthy pass.
- Before any repair command, sweep dangling thunderd.service
  enable-symlinks under /etc|/run/systemd/system's .wants/.requires
  dirs (exact name, verified dangling) and daemon-reload if anything
  was removed -- the same GC hazard from the incident, defended here
  for nodes whose CLI predates the thundernetes-side fix.

Deviations from the plan, both intentional, flagged for review:
- The saved-token-invalid-per-Central branch of enroll() is not
  implemented: the daemon has no SDK path to call the token-validate
  endpoint with the node's own device credential (it only lives in the
  thundernetes CLI's unexported centralDeviceAuthClient), so per the
  plan's own fallback this stays a human decision.
- "CLI too old for restart" is not detected as a separate signal from
  "CLI missing": RunShell does not return command output, so there is
  no reliable way to distinguish a stale-flag failure from any other
  restart failure. Both cases are bounded by the same repair budget and
  cool-off instead.
…ool-off

Requested change: once 5 repair attempts have failed for the current
outage, stop repairing entirely -- no more restarts, CLI reinstalls, or
enrolls of any kind -- rather than retrying a bare restart once an
hour. Removes the now-dead cool-off timestamp/flag and the clock hook
that only existed to test it; repairBudgetMarker is just an attempt
count. The budget still resets on the next healthy pass.
Stamp the repair budget marker with version.Get() (already wired to
-ldflags at build time). When the running daemon's version differs
from the marker's, discard the old budget and log once ("new daemon
version, retrying repairs") before continuing -- a new image may
contain a fix a spent budget from the old one shouldn't block.

Also cut the multi-sentence comments added across B1-B4 down to one
or two lines each, dropping incident/cross-repo references while
keeping the "(by claude)" tags.
The daemon image rarely changes; the thunderd distribution does, and
repairs install whatever it currently serves. Fetch
<ArtifactBaseURL>/sha256sums.txt (anonymous) and stamp thunderd's
checksum in the marker, but only once the budget is already spent --
healthy nodes never poll for it, and it rides the existing reconcile
cadence rather than a separate timer.

A changed checksum resets the budget and reattempts the repair in the
same pass, logging once ("distribution serves a new thunderd, retrying
repairs"). A fetch error leaves the budget untouched and given up,
with no log spam. The daemon-version reset from the previous commit
stays as a secondary trigger; both keys live in the marker.
thunder up never replaces an existing binary, so a checksum-triggered
retry now runs thunder update instead -- only for that one retry pass;
every other attempt in the round still uses the normal action (thunder
up for restarts).

Marker gains AttemptedChecksum (the sha the retry installed) and
FailedChecksum (a sha whose retry round failed and must not reset the
budget again): the reset condition is now served-sha != Attempted AND
served-sha != Failed. When an update-triggered round spends the budget
a second time, thunder update --rollback is tried once (best-effort --
today's CLI has no --rollback flag, so this normally fails, logs one
line, and the checksum is still recorded as failed) before the usual
give-up log.

repairAttempt takes two new optional callbacks (updateAction,
rollbackAction) so enroll/CLI-reinstall, where none of this applies,
pass nil and are unaffected.
Arda/repair loop cap on the dra side for auto-updates.
…c1421c33771b44f4d63096998f22097fb4b89

chore: pin images for 5edc142

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Enrollment budget blocks later recovery
    • Removed repairAttempt wrapper from enrollment path to allow unlimited retries via reconcile loop backoff, preventing permanent enrollment stalls when NVIDIA driver or Thunder API appears after initial failures.

Create PR

Or push these changes by commenting:

@cursor push db6069dd7d
Preview (db6069dd7d)
diff --git a/internal/daemon/reconcile.go b/internal/daemon/reconcile.go
--- a/internal/daemon/reconcile.go
+++ b/internal/daemon/reconcile.go
@@ -189,15 +189,13 @@ func (r *reconciler) ensureEnrolled(ctx context.Context, cfg Config) error {
 
 	if !r.nodeEnrolled(cfg, status, statusErr) {
 		log.Printf("thunderd unhealthy on node %s after %d check(s); enrolling", cfg.Node, r.unhealthy)
-		attempted, err := r.repairAttempt(ctx, cfg, func(ctx context.Context) error {
-			return r.enroll(ctx, cfg)
-		}, nil, nil)
-		if err != nil {
-			return err
+		if err := r.sweepDanglingSymlinks(ctx); err != nil {
+			log.Printf("node %s: could not sweep dangling thunderd symlinks, enrolling anyway: %v", cfg.Node, err)
 		}
-		if attempted {
-			r.unhealthy, r.transitional = 0, 0
+		if err := r.enroll(ctx, cfg); err != nil {
+			return err
 		}
+		r.unhealthy, r.transitional = 0, 0
 		return nil
 	}
 

@@ -189,15 +189,13 @@ func (r *reconciler) ensureEnrolled(ctx context.Context, cfg Config) error {
 
 	if !r.nodeEnrolled(cfg, status, statusErr) {
 		log.Printf("thunderd unhealthy on node %s after %d check(s); enrolling", cfg.Node, r.unhealthy)
-		attempted, err := r.repairAttempt(ctx, cfg, func(ctx context.Context) error {
-			return r.enroll(ctx, cfg)
-		}, nil, nil)
-		if err != nil {
-			return err
+		if err := r.sweepDanglingSymlinks(ctx); err != nil {
+			log.Printf("node %s: could not sweep dangling thunderd symlinks, enrolling anyway: %v", cfg.Node, err)
 		}
-		if attempted {
-			r.unhealthy, r.transitional = 0, 0
+		if err := r.enroll(ctx, cfg); err != nil {
+			return err
 		}
+		r.unhealthy, r.transitional = 0, 0
 		return nil
 	}

You can send follow-ups to the cloud agent here.

Comment thread internal/daemon/reconcile.go Resolved
Comment thread internal/daemon/repair_budget.go Outdated
if err != nil {
return fmt.Errorf("encode repair budget marker: %w", err)
}
command := "set -eu; umask 077; mkdir -p /var/lib/thunder; tmp=$(mktemp /var/lib/thunder/.repair-budget.XXXXXX); trap 'rm -f \"$tmp\"' EXIT; printf %s " + shellQuote(string(data)) + " > \"$tmp\"; mv \"$tmp\" " + shellQuote(repairBudgetMarkerPath) + "; trap - EXIT"

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.

prob don't need to have a repair budget, it's ok if it restarts a bunch of times

Arda Akman and others added 5 commits August 25, 2026 02:48
Review feedback (brianmodel): a pod restart resetting the repair
counter is an acceptable tradeoff for not writing a marker file to the
host. Delete repair_budget.go's marker/store machinery entirely (the
mktemp/mv shell write included) and keep the same transitions --
5-attempt cap with one give-up log, checksum-triggered reset retried
via `thunder update`, failed-sha blacklist, one-shot rollback, reset on
a healthy pass -- as plain fields on *reconciler instead.

Also drops the daemon-version reset and its DaemonVersion field: with
state in memory, a new daemon image is a new pod is fresh state
already, so the mechanism is redundant.

Tests: dropped the persistence-across-reconciler-recreation case
(no longer applicable) and the daemon-version reset test (mechanism
gone); the give-up/healthy-reset test now runs against a single
reconciler instance. Checksum retry, blacklist, and rollback tests
adapted to read state off the reconciler directly instead of a fake
store.
Root cause: the publish job's permissions block grants contents,
packages, and id-token but not pull-requests, so the final "Open chart
values pull request" step's `gh pr create` (using GITHUB_TOKEN) is
refused by the API on every run. The branch push just before it
succeeds under contents: write, which is why automation/image-tags-*
branches exist on their own with no PR -- they had to be opened by
hand every time. Adding pull-requests: write lets that same token
open the PR the step already builds.
User rule: no multi-line bash embedded as Go strings. Replaces
sweepDanglingSymlinksCommand (a set -eu/for/trap shell script run
through RunShell) with danglingThunderdSymlinks, which does the
detection in Go against the read-only /host mount: filepath.Glob over
the four .wants/.requires locations, os.Lstat to confirm each
thunderd.service entry is a symlink, os.Stat to confirm the target is
gone. sweepDanglingSymlinks then issues plain argv commands through
the runner (no shell) -- one `rm -f` naming every dangling path found,
then `systemctl daemon-reload` only if something was removed -- and
issues nothing at all when detection finds nothing dangling.

Behavior is unchanged: exact "thunderd.service" name, both locations
under /etc and /run, dangling-only, reload only on removal.

Tests: scriptedRunner gained generic CombinedOutput recording (it only
tracked the `thunder status` script before) and a call-order sequence
spanning both CombinedOutput and RunShell, since the sweep and the
repair action it precedes can now go through either. Rewrote the two
tests that pinned the sweep to the old script string to instead plant
a real dangling symlink under a temp HostRoot and assert on the
rm/daemon-reload commands and their ordering before the repair; added
a case asserting no commands are issued when nothing is dangling, and
a direct unit test for danglingThunderdSymlinks covering a live
symlink, a plain file, and an unrelated name, none of which should be
touched.

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Absolute symlink check uses container root
    • Modified danglingThunderdSymlinks to read symlink targets and resolve absolute paths against hostRoot before checking existence, preventing valid host symlinks from being incorrectly deleted.

Create PR

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 66a20fa. Configure here.

Comment thread internal/daemon/reconcile.go Resolved
cursoragent and others added 4 commits August 25, 2026 03:20
The danglingThunderdSymlinks function was incorrectly checking if symlink
targets exist by using os.Stat on the container-mounted path. When symlinks
have absolute targets (e.g., /lib/systemd/system/thunderd.service), os.Stat
resolves them from the container root instead of the host filesystem.

This caused valid systemd enable-symlinks on the host to appear dangling,
leading to their incorrect deletion during the sweep.

The fix reads the symlink target and explicitly resolves absolute paths
against hostRoot before checking existence, matching the previous
nsenter-based behavior.
…7d20bbefc842d5dd7bc6b8acab7e3f73d498f

chore: pin images for ce67d20
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.

5 participants