Skip to content

feat(foreman): report the Kubernetes node on FleetNode.status, not as identity - #1649

Merged
Defilan merged 2 commits into
defilantech:mainfrom
Defilan:feat/1640-kubernetes-node
Aug 24, 2026
Merged

feat(foreman): report the Kubernetes node on FleetNode.status, not as identity#1649
Defilan merged 2 commits into
defilantech:mainfrom
Defilan:feat/1640-kubernetes-node

Conversation

@Defilan

@Defilan Defilan commented Aug 23, 2026

Copy link
Copy Markdown
Member

What

Implement the #1640 decision: FleetNode identity is the agent process, and
the Kubernetes node it runs on becomes a reported property
status.kubernetesNode.

Why

Refs #1640

The chart has set FLEET_NODE_NAME from the downward API (fieldRef: spec.nodeName) since M4c, but no Go code ever read it. The agent falls back to
os.Hostname() — the pod name — so every replica registers its own FleetNode.
The decision on #1640 established that fallback as the correct model:
spec.nodeName's contract was always worker-scoped, and every
FleetNode.status mechanism (one currentTask reservation, heartbeat-driven
claim expiry and the Draining reaper, phase) assumes a single writer. Wiring
the env var into identity would break all of them at once and reintroduce the
two-agents claim race.

What was genuinely missing is the machine as a property: nothing recorded
which physical node an in-cluster agent pod runs on. On a fleet of two GB10s, a
Strix and metal Macs, "which box is this agent on?" was unanswerable from the CR.

How

  • status.kubernetesNode, stamped on heartbeat alongside os/arch, fed
    from FLEET_NODE_NAME — the previously-dead env var, repurposed rather than
    deleted. Off-cluster agents leave it empty (covered by a test).
  • spec.nodeName godoc corrected to state it is the worker's own name (the
    pod name in-cluster) and that the Kubernetes node lives at
    status.kubernetesNode. The field is not renamed — that is a CRD break,
    deferred per the decision.
  • resolveFleetNodeName extracted from main() and tested, pinning the
    identity-source contract ([BUG] FLEET_NODE_NAME is set from spec.nodeName but never read, so FleetNode identity is the pod, not the node #1640 ask 3): the --fleet-node-name flag wins,
    otherwise the sanitized hostname; FLEET_NODE_NAME is deliberately not an
    input.
  • CRDs regenerated: make manifests, chart-crds, foreman-chart-crds;
    make check-helm-rbac green.

The kube-state-metrics CustomResourceState label for the new field rides with
the #1643 CRS fix, where that config file is already being rewritten — adding it
here would guarantee a conflict between the two branches.

Follow-up

#1648 (StatefulSet stable identity) is the named follow-up for the churn cost
this decision accepts.

Verification

  • Watched both registrar tests and the resolver test fail first (missing field /
    missing function), then pass
  • go test ./pkg/foreman/agent/ ./cmd/foreman-agent/ ./api/foreman/... green
  • make lint, GOOS=linux cross-arch lint, make lint-deadcode: 0 issues
  • make check-helm-rbac: green; helm unittest charts/foreman: 39/39

Checklist

  • Tests added/updated
  • make test passes locally
  • make lint passes locally
  • Commit messages follow conventional commits
  • All commits are signed off (git commit -s) per DCO
  • AI assistance (if any) is disclosed above, per CONTRIBUTING.md
  • Documentation updated (if user-facing change) — CRD godoc is the user-facing surface here

Assisted-by: Claude Code (implemented under TDD from the #1640 decision I made
after its architecture study; I reviewed the change and ran the tests, both
lints, the RBAC check and the chart suite before submitting).

… identity

Implements the defilantech#1640 decision: FleetNode identity is the agent PROCESS, and
the machine it runs on is a reported property.

The chart has set FLEET_NODE_NAME from the downward API (fieldRef:
spec.nodeName) since M4c, but no Go code ever read it, so the agent fell
back to os.Hostname() -- the pod name -- and every replica registered its
own FleetNode. That fallback is the CORRECT model: spec.nodeName's contract
was always "the human-readable identity of the worker", and every
FleetNode.status mechanism (currentTask, heartbeat-driven claim expiry and
the Draining reaper, phase) assumes a single writer. Wiring the env var
into identity would break all of them and reintroduce the two-agents claim
race. See defilantech#1640 for the full analysis.

What was genuinely missing is the machine as a PROPERTY: nothing recorded
which physical node an in-cluster agent pod runs on, which on a
heterogeneous fleet is a real gap. So the previously-dead env var is
repurposed rather than deleted:

- FleetNode.status gains kubernetesNode, stamped on heartbeat alongside
  os/arch. Off-cluster agents (the metal Macs) leave it empty.
- The agent feeds it from FLEET_NODE_NAME, which is finally read by
  something.
- spec.nodeName's godoc now states plainly that it is the worker's own
  name -- the pod name in-cluster -- and that the Kubernetes node lives at
  status.kubernetesNode. The field is NOT renamed; that is a CRD break,
  deferred unless a v1alpha2 happens for other reasons.
- resolveFleetNodeName is extracted from main() and tested, pinning the
  identity-source contract: the --fleet-node-name flag wins, otherwise the
  sanitized hostname, and FLEET_NODE_NAME is deliberately not an input.

CRDs regenerated (config + foreman chart copies). The kube-state-metrics
CustomResourceState label for the new field rides with the defilantech#1643 CRS fix,
where that config file is already being rewritten.

Refs defilantech#1640

Signed-off-by: Christopher Maher <chris@mahercode.io>
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.33333% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/foreman-agent/main.go 26.31% 14 Missing ⚠️

📢 Thoughts on this report? Let us know!

joryirving added a commit to joryirving/LLMKube that referenced this pull request Aug 23, 2026
The comment justified Recreate by two agents racing for the same Scheduled
tasks. That race cannot happen: FLEET_NODE_NAME is dead wiring, so each
replica registers its own FleetNode from its pod name, and pollOnce claims
only tasks assigned to its own node (defilantech#1640, resolved to process-scoped
identity in defilantech#1649). Wording per the maintainer's suggestion on defilantech#1635; the
blast-radius half is unchanged.

Signed-off-by: Jory Irving <jory@jory.dev>

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

This is right, and it is the right shape: identity stays the agent process, the machine becomes a property. I checked the premise rather than taking it, and it holds. charts/foreman/templates/agent-deployment.yaml:112 does set FLEET_NODE_NAME from spec.nodeName, and before this PR nothing in Go read it, so the pod-name fallback at the old main.go:279 really was the effective behaviour. The resolveFleetNodeName extraction is behaviour-preserving (same flag-wins ordering, same error on empty or failed hostname, same sanitize-only-when-user-supplied log line), and sanitizeName already trims, truncates to 63 and falls back, so the "DNS-1123 label" claim in the new godoc is honest. Both CRD copies match the Go comments verbatim and there are only two copies, so the regen story is complete. Off-cluster is real too, not aspirational: deployment/macos/com.llmkube.foreman-agent.plist:23 and deployment/linux/llmkube-foreman-agent.service:32 both pass --fleet-node-name.

One test does not test what its comment says. TestRegistrar_PatchHeartbeat_OmitsKubernetesNodeWhenUnset (pkg/foreman/agent/fleetnode_test.go:602) claims "an empty value must not be stamped as anything", but it cannot distinguish the guard from its absence: I deleted if r.KubernetesNode != "" at pkg/foreman/agent/fleetnode.go:253 and made the assignment unconditional, and both new tests still pass. On a freshly created node an unconditional "" writes "" either way. The case that separates them is the one that actually matters: a FleetNode whose status already carries a non-empty kubernetesNode, patched by a Registrar with the field empty. With the guard the old value sticks forever; without it, MergeFrom plus omitempty clears it. I would add that case (Upsert, PatchHeartbeat with KubernetesNode set, then a second Registrar with it empty, assert the value you actually want) so the sticky semantics are a decision on the record rather than an accident. Keeping the guard for consistency with OS and Arch is fine by me; it just needs to be the tested behaviour.

Second, the field is currently write-only. Nothing in the repo reads status.kubernetesNode, and it is not a printer column, so the motivating question ("which box is this agent on?") is still only answerable with -o yaml. Adding a +kubebuilder:printcolumn for it next to api/foreman/v1alpha1/fleetnode_types.go:222 costs nothing and is not a CRD break, and it is the fix that matches the stated motivation. The kube-state-metrics label deferral checks out (#1650 does carry kubernetes_node: [status, kubernetesNode] plus a chart test asserting that pattern), so worth noting the ordering: if #1650 lands first its config labels a field the CRD does not have yet. Harmless, just less confusing merged the other way round.

Third, the docs the issue was about are still wrong. docs/site/foreman/README.md:58 says the foreman-agent "on each host self-registers", and :116 describes the chart as registering "a FleetNode for the gate-runner role on the Linux/K8s host". Under this decision it is one FleetNode per agent process, so a pool with replicaCount: 2 gets two on one host, which is exactly what #1640 showed live. That is a two-line fix and it belongs in this PR rather than behind the CRD-godoc justification in the checklist, because the whole point here is that a documented invariant was inverted.

On #1635: no conflict, and this helps it. I test-merged fix/agent-jobmode-slot into this branch and all four shared files (fleetnode_types.go, both CRD YAMLs, main.go) auto-merged clean, since #1635 touches currentTask's godoc and the watcher wiring while this touches the Arch neighbourhood and the resolve block. More usefully, #1635's Recreate comment and its new README section both assume identity is per agent process, so nailing that down as design here removes the risk that #1640 gets closed the other way and breaks them.

…orrect the docs

Review follow-ups on the defilantech#1640 status.kubernetesNode change.

The two heartbeat tests could not tell the `r.KubernetesNode != ""`
guard apart from its absence: on a freshly created FleetNode an
unconditional "" write is indistinguishable from no write at all, so
deleting the guard left both green. Add the case that separates them,
a node already carrying a non-empty kubernetesNode patched by a
Registrar with the field unset. Without the guard, MergeFrom plus the
field's omitempty tag emits kubernetesNode: null and the stored value
is lost; with it the property is sticky, matching how OS and Arch
already behave. Keeping the value sticky is now the tested behaviour
rather than an accident.

The field was also write-only: nothing read it and it was not a
printer column, so "which box is this agent on" still needed -o yaml.
Add a K8s Node printcolumn (additive, not a CRD break) and regenerate
both the config and foreman chart CRD copies.

Finally, correct the two README lines the issue was actually about.
They described one FleetNode per host, which defilantech#1640 inverted: identity
is the agent process, so a pool with replicaCount: 2 yields two
FleetNodes on one host, each reporting the same status.kubernetesNode.

Refs defilantech#1640

Signed-off-by: Christopher Maher <chris@mahercode.io>
@Defilan

Defilan commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

All three applied, pushed fd09bd709.

The test finding was the useful one, and I reproduced your method: deleting the guard leaves both original tests green, because on a freshly created node an unconditional empty write is indistinguishable from no write. The new case is the one you described, a node already carrying a non-empty kubernetesNode patched by a Registrar with the field empty, and it fails when the guard is removed. Sticky is now the tested decision rather than an accident, consistent with OS and Arch.

Added the K8s Node printcolumn. You are right that a write-only field does not answer the question the issue was about. Worth noting for anyone else touching a foreman.llmkube.dev CRD: make chart-crds is the wrong target and quietly no-ops, it needs make foreman-chart-crds.

Fixed both README lines. That one stings a bit, since the whole point of the issue was that a documented invariant had been inverted, and I left the inverted documentation in place.

On ordering with #1650: agreed, this should land first so the CRD has the field before the kube-state-metrics config labels it.

@Defilan
Defilan merged commit 7563e8a into defilantech:main Aug 24, 2026
25 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 24, 2026
doonga pushed a commit to greyrock-labs/home-ops that referenced this pull request Aug 24, 2026
…mkube (0.9.19 ➔ 0.9.20) (#402)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/home-operations/charts-mirror/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.19` → `0.9.20` |

---

### Release Notes

<details>
<summary>defilantech/LLMKube (ghcr.io/home-operations/charts-mirror/llmkube)</summary>

### [`v0.9.20`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0920-2026-08-24)

[Compare Source](defilantech/LLMKube@v0.9.19...v0.9.20)

##### Features

- **foreman:** opt-in archival of task audit records and transcripts ([#&#8203;1655](defilantech/LLMKube#1655)) ([c577133](defilantech/LLMKube@c577133))
- **foreman:** report the Kubernetes node on FleetNode.status, not as identity ([#&#8203;1649](defilantech/LLMKube#1649)) ([7563e8a](defilantech/LLMKube@7563e8a))

##### Bug Fixes

- **controller:** carry Model tolerations onto the prefetch Job ([#&#8203;1622](defilantech/LLMKube#1622)) ([d0db838](defilantech/LLMKube@d0db838))
- **controller:** clear the controller's own schedulingStatus once a service is Ready ([#&#8203;1633](defilantech/LLMKube#1633)) ([315f34d](defilantech/LLMKube@315f34d))
- Empty payload.repo silently defeats the upstream-base fetch: task branches cut from a stale fork HEAD ([#&#8203;1625](defilantech/LLMKube#1625)) ([#&#8203;1626](defilantech/LLMKube#1626)) ([27a015a](defilantech/LLMKube@27a015a))
- **foreman:** carry the in-pod result extras on every Job-mode branch ([#&#8203;1657](defilantech/LLMKube#1657)) ([d1ce51c](defilantech/LLMKube@d1ce51c))
- **foreman:** free the agent's in-process slot while a Job-mode task runs ([#&#8203;1635](defilantech/LLMKube#1635)) ([0d64fbb](defilantech/LLMKube@0d64fbb))
- **foreman:** gate the make-invoked CI checks, and pin them against the workflows ([#&#8203;1642](defilantech/LLMKube#1642)) ([4e7ab7b](defilantech/LLMKube@4e7ab7b))
- **foreman:** make the CustomResourceState config produce usable metrics ([#&#8203;1650](defilantech/LLMKube#1650)) ([1620443](defilantech/LLMKube@1620443))

##### Documentation

- add ROCm host-retune runbook for >64GB on Strix Halo ([#&#8203;1387](defilantech/LLMKube#1387)) ([fff5e59](defilantech/LLMKube@fff5e59))
- fix guide instructions that fail against the shipped API ([#&#8203;1629](defilantech/LLMKube#1629)) ([fba0bf4](defilantech/LLMKube@fba0bf4))
- **proposals:** foreman run, an unattended orchestration loop ([#&#8203;1652](defilantech/LLMKube#1652)) ([df57f49](defilantech/LLMKube@df57f49))
- reframe the multi-GPU guide from an Issue [#&#8203;2](defilantech/LLMKube#2) validation plan into a deployment guide ([#&#8203;1631](defilantech/LLMKube#1631)) ([907ac8d](defilantech/LLMKube@907ac8d))
- **runbook:** correct the metal-agent memory-pressure runbook against pkg/agent ([#&#8203;1630](defilantech/LLMKube#1630)) ([f665674](defilantech/LLMKube@f665674))
- serving one model across two DGX Sparks via llama.cpp RPC ([#&#8203;1620](defilantech/LLMKube#1620)) ([7eaf04b](defilantech/LLMKube@7eaf04b))

</details>

---

### Configuration

📅 **Schedule**: (in timezone America/New_York)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC40MC4wIiwidXBkYXRlZEluVmVyIjoiNDQuNDAuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsicmVub3ZhdGUvY29udGFpbmVyIiwidHlwZS9wYXRjaCJdfQ==-->

Reviewed-on: https://git.greyrock.io/greyrock-labs/home-ops/pulls/402
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.

2 participants