feat(foreman): report the Kubernetes node on FleetNode.status, not as identity - #1649
Conversation
… 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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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
left a comment
There was a problem hiding this comment.
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>
|
All three applied, pushed 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 Added the 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. |
…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 ([#​1655](defilantech/LLMKube#1655)) ([c577133](defilantech/LLMKube@c577133)) - **foreman:** report the Kubernetes node on FleetNode.status, not as identity ([#​1649](defilantech/LLMKube#1649)) ([7563e8a](defilantech/LLMKube@7563e8a)) ##### Bug Fixes - **controller:** carry Model tolerations onto the prefetch Job ([#​1622](defilantech/LLMKube#1622)) ([d0db838](defilantech/LLMKube@d0db838)) - **controller:** clear the controller's own schedulingStatus once a service is Ready ([#​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 ([#​1625](defilantech/LLMKube#1625)) ([#​1626](defilantech/LLMKube#1626)) ([27a015a](defilantech/LLMKube@27a015a)) - **foreman:** carry the in-pod result extras on every Job-mode branch ([#​1657](defilantech/LLMKube#1657)) ([d1ce51c](defilantech/LLMKube@d1ce51c)) - **foreman:** free the agent's in-process slot while a Job-mode task runs ([#​1635](defilantech/LLMKube#1635)) ([0d64fbb](defilantech/LLMKube@0d64fbb)) - **foreman:** gate the make-invoked CI checks, and pin them against the workflows ([#​1642](defilantech/LLMKube#1642)) ([4e7ab7b](defilantech/LLMKube@4e7ab7b)) - **foreman:** make the CustomResourceState config produce usable metrics ([#​1650](defilantech/LLMKube#1650)) ([1620443](defilantech/LLMKube@1620443)) ##### Documentation - add ROCm host-retune runbook for >64GB on Strix Halo ([#​1387](defilantech/LLMKube#1387)) ([fff5e59](defilantech/LLMKube@fff5e59)) - fix guide instructions that fail against the shipped API ([#​1629](defilantech/LLMKube#1629)) ([fba0bf4](defilantech/LLMKube@fba0bf4)) - **proposals:** foreman run, an unattended orchestration loop ([#​1652](defilantech/LLMKube#1652)) ([df57f49](defilantech/LLMKube@df57f49)) - reframe the multi-GPU guide from an Issue [#​2](defilantech/LLMKube#2) validation plan into a deployment guide ([#​1631](defilantech/LLMKube#1631)) ([907ac8d](defilantech/LLMKube@907ac8d)) - **runbook:** correct the metal-agent memory-pressure runbook against pkg/agent ([#​1630](defilantech/LLMKube#1630)) ([f665674](defilantech/LLMKube@f665674)) - serving one model across two DGX Sparks via llama.cpp RPC ([#​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
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_NAMEfrom the downward API (fieldRef: spec.nodeName) since M4c, but no Go code ever read it. The agent falls back toos.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 everyFleetNode.statusmechanism (onecurrentTaskreservation, heartbeat-drivenclaim expiry and the Draining reaper,
phase) assumes a single writer. Wiringthe 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 alongsideos/arch, fedfrom
FLEET_NODE_NAME— the previously-dead env var, repurposed rather thandeleted. Off-cluster agents leave it empty (covered by a test).
spec.nodeNamegodoc corrected to state it is the worker's own name (thepod 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.
resolveFleetNodeNameextracted frommain()and tested, pinning theidentity-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-nameflag wins,otherwise the sanitized hostname;
FLEET_NODE_NAMEis deliberately not aninput.
make manifests,chart-crds,foreman-chart-crds;make check-helm-rbacgreen.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
missing function), then pass
go test ./pkg/foreman/agent/ ./cmd/foreman-agent/ ./api/foreman/...greenmake lint,GOOS=linuxcross-arch lint,make lint-deadcode: 0 issuesmake check-helm-rbac: green;helm unittest charts/foreman: 39/39Checklist
make testpasses locallymake lintpasses locallygit commit -s) per DCOAssisted-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).