Conversation
…rom ConfigMaps A SeiNode may name ConfigMaps supplying its config.toml and app.toml. Both mount read-only over the seid config directory, and what the operator wrote is what seid reads. The controller adds nothing on top. A node with the field takes a plan progression carrying no config task at all. That is what keeps the mount attached: a rename onto a mounted path from another mount namespace succeeds and detaches the mount, after which seid reads the writer's file with nothing reporting the swap. The kernel's EBUSY guard is is_local_mountpoint(), scoped to the calling namespace, and vfs_rename() calls detach_mounts(). Verified on Linux 6.12.68. staticConfigPlanner wraps the mode planner rather than replacing it, so each mode keeps its own Validate and its own init plan. It owns the Running arm, which the mode planners route through an assembler that force-inserts config-apply whenever the configValues baseline is unobserved. MountsNodeConfig is the predicate everywhere: the union of spec.nodeConfig and status.currentNodeConfig, because a node mid-revert still has the mount while its spec no longer names a ConfigMap. config-validate is stripped too. It reports on a file the operator owns, through sei-config's legacy reader, which falls back to mode "full" when app.toml carries no [sei] mode — so on a validator it passes a config seid refuses. replace-pod parses both ConfigMaps before it deletes a pod, and that is the check that matters. Rejected beside the field, each because its only route to seid was a config task: configValues, overrides, peers, externalAddress. peers is the sharpest — status.resolvedPeers would keep updating and keep looking correct while config.toml stayed as written. Refused by staticConfigPlanner.Validate, each because it learns config at run time that no ConfigMap can hold: a bootstrap Job (its pod holds the same PVC and rewrites config.toml there), a state-sync snapshot source, a genesis ceremony, and consensus engine Autobahn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR SummaryHigh Risk Overview The controller tracks Reviewed by Cursor Bugbot for commit 707e8d1. Bugbot is set up for automated code reviews on this repo. Configure here. |
golangci-lint flagged eleven repeated string literals across the new files. The planner tests reuse the package's existing constants; the task tests gain one for the fixture ConfigMap name. config.toml and app.toml were restated in replace_pod.go, which checks a ConfigMap the renderer mounts. The renderer now exports ConfigTomlKey and AppTomlKey and the guard consumes them, so the mount contract has one owner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Since my last read the only change is that noderesource now exports ConfigTomlKey/AppTomlKey (the unexported mount keys alias them) and replace-pod's guard references those instead of literals, plus test fixture constants — a tidy-up that single-sources the key names and addresses none of my findings. The revert path still rolls a node back to controller management without ever writing controller config, so the verdict stands; from the other reading I kept the ConfigFileRef.Name validation gap and dropped its observe-image stamping claim, because the rollout gate (ObservedGeneration plus UpdatedReplicas against the current revision) cannot report complete for a pod at an older template, so newer refs cannot be stamped over an older pod — the real consequence of that window is the indefinite poll I list as pre-existing.
Blocking
1 finding on the changed lines, as inline comments.
Non-blocking
2 findings on the changed lines, as inline comments.
- Nothing stops spec.nodeConfig being set on a SeiNetwork-owned child, and the parent's unconditional sync of spec.overrides / spec.configValues (internal/controller/seinetwork/nodes.go) then fails the new CEL rules on every reconcile. Because ensureSeiNode issues one Update carrying the whole spec, that also takes image, label and sidecar propagation down for the network — the same blast radius the spec.resources comment there already warns about. Either reject nodeConfig on a controlled child or skip those syncs when it is set.
- The four staticConfigPlanner.Validate refusals are plan-time, but reconcileStatefulSet applies the template unconditionally and earlier in the reconcile, so setting nodeConfig on a state-sync, Autobahn, bootstrap or ceremony node mutates the pod template before the refusal is reached. OnDelete spares the live pod, but the next eviction or manual delete brings up a pod mounting operator config on exactly the shapes the refusal exists to prevent. CEL cannot express these, so a render-time hold (as with the unresolved-reference residual) is the lever.
Pre-existing
Already true on the base branch, not introduced here.
- suggestion — internal/task/observe_image.go polls with no deadline and the executor has no task timeout, so if the pod template changes after replace-pod but before observe-image completes, UpdatedReplicas never reaches Replicas under OnDelete and the plan stays Active forever — no new plan is built and NodeUpdateInProgress never clears. True on base for an image edit mid-roll; nodeConfig makes it far more reachable, since republishing under a new name is the documented way to change config.
seidroid review · decision request_changes · session 67e448f06da64676ac86fd92accb0b2a · turn resp_claude_cd2c925a838be810f524b8c8bda3655b · item 51bcea1323a95938b2df78727f4a4667
Findings: 1 blocking | 4 non-blocking | 3 posted inline | 1 pre-existing
| // key-validation gates lead, as they do in every mode's update plan, so a | ||
| // missing Secret fails controller-side rather than as a kubelet mount error on | ||
| // the recreated pod. | ||
| func (p *staticConfigPlanner) buildUpdatePlan(node *seiv1alpha1.SeiNode) (*seiv1alpha1.TaskPlan, error) { |
There was a problem hiding this comment.
blocker — The revert plan rolls the pod but never restores controller-managed config, so a node born with nodeConfig comes back on bare seid init output. config-apply never ran on such a node (withoutManagedConfigTasks stripped it from the init plan) and the ConfigMap content only ever existed as a bind mount, so the PVC's config.toml is the seid-init default — RPC laddr on 127.0.0.1, no persistent-peers, no mode/pruning in app.toml. After this plan completes, observe-image stamps currentNodeConfig=nil, the node returns to the mode planner, podTemplateDrifted is false and no further plan is built (pinned by TestNodeConfigRevertRollsBeforeAnyConfigWrite), so the seid container fails its RPC StartupProbe indefinitely while the plan reports success. Note the fix cannot be an extra task here: mountedConfigWriterInPlan keys on the union including the stamp, so it would refuse this very plan — the write has to come from a follow-up plan once the stamp has cleared.
| // ContainerCreating and `kubectl describe pod` names the missing key. | ||
| // +kubebuilder:validation:MinLength=1 | ||
| // +kubebuilder:validation:MaxLength=253 | ||
| Name string `json:"name"` |
There was a problem hiding this comment.
suggestion — Name carries only length bounds, so a value that is not a DNS subdomain passes admission and then renders a StatefulSet the API server rejects, leaving apply-statefulset failing every reconcile. The sibling object-name fields in this file (DataVolumeImport.PVCName, volumeAttributesClassName) both pin the DNS pattern; adding it here rejects the typo at kubectl apply. (Raised by codex and confirmed.)
| // It wraps the node's mode planner rather than replacing it, so the mode keeps | ||
| // its own Validate and its own init and bootstrap plans. Those plans are | ||
| // already safe: both production-pod progressions run through | ||
| // withoutMountedConfigWriters. What the wrapper owns is the Running arm, which |
There was a problem hiding this comment.
suggestion — This comment names withoutMountedConfigWriters, which does not exist, and its claim is wrong in a load-bearing way: only buildBasePlan runs the filter. The bootstrap and genesis-ceremony progressions (bootstrap.go:132, bootstrap.go:170) are never filtered — they are safe because Validate refuses those shapes. A reader adding a new non-base builder would take this comment as coverage it does not have.
Clearing spec.nodeConfig rolled the pod to drop the mounts and then settled with no follow-up plan. A node created with nodeConfig never ran config-apply, so the volume held only what `seid init` wrote: no mode base, no freeze height, no snapshot-generation keys. After the revert seid read those defaults and the node reported success. The revert plan now appends config-apply and config-validate after observe-image. The replacement pod carries no mount by then, so the rename lands on a plain file, and the node leaves with the configuration its mode expects. mountedConfigWriterInPlan gains the matching exemption: on a revert, tasks after replace-pod run against a pod the template no longer gives the mounts. Nothing else is exempt. Reported by Cursor Bugbot on #565. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
This revision answers my earlier blocker: a revert plan now appends config-apply and config-validate after replace-pod, and mountedConfigWriterInPlan exempts tasks past the replacement, so a reverted node no longer keeps seid init defaults on disk. It is still incomplete — the write lands on a pod whose seid already started and nothing restarts it — which is codex's first finding, kept after checking the seid container's healthz-only start gate and the repo's own config-update progression; I also kept codex's ConfigFileRef.Name validation gap and again dropped its observe-image stamping claim, since the rollout gate cannot report complete for a pod at an older revision and the real consequence of that window is the indefinite poll I list as pre-existing.
Blocking
1 finding on the changed lines, as inline comments.
Non-blocking
2 findings on the changed lines, as inline comments.
- Nothing stops spec.nodeConfig being set on a SeiNetwork-owned child, and the parent's unconditional sync of spec.overrides / spec.configValues (internal/controller/seinetwork/nodes.go) then fails the new CEL rules on every reconcile. Because ensureSeiNode issues one Update carrying the whole spec, that also takes image, label and sidecar propagation down for the network — the same blast radius the spec.resources comment there already warns about. Either reject nodeConfig on a controlled child or skip those syncs when it is set.
- The four staticConfigPlanner.Validate refusals are plan-time, but reconcileStatefulSet applies the template unconditionally and earlier in the reconcile, so setting nodeConfig on a state-sync, Autobahn, bootstrap or ceremony node mutates the pod template before the refusal is reached. OnDelete spares the live pod, but the next eviction or manual delete brings up a pod mounting operator config on exactly the shapes the refusal exists to prevent. CEL cannot express these, so a render-time hold (as with the unresolved-reference residual) is the lever.
Pre-existing
Already true on the base branch, not introduced here.
- suggestion — internal/task/observe_image.go polls with no deadline and the executor has no task timeout, so if the pod template changes after replace-pod but before observe-image completes, UpdatedReplicas never reaches Replicas under OnDelete and the plan stays Active forever — no new plan is built and NodeUpdateInProgress never clears. True on base for an image edit mid-roll; nodeConfig makes it far more reachable, since republishing under a new name is the documented way to change config.
seidroid review · decision request_changes · session 67e448f06da64676ac86fd92accb0b2a · turn resp_claude_acd236cd361893a6eda5274b0efd99c1 · item 0f4e66156f69536b8630c93967ef61e3
Findings: 1 blocking | 4 non-blocking | 3 posted inline | 1 pre-existing
| // node created with nodeConfig has only what `seid init` left on the | ||
| // volume: no mode base, no freeze height, no snapshot-generation keys. | ||
| // Without this the node keeps those defaults and reports success. | ||
| prog = append(prog, TaskConfigApply, TaskConfigValidate) |
There was a problem hiding this comment.
blocker — config-apply lands after the replacement pod is already running, and nothing restarts seid, so the node is marked ready on the configuration this write replaced. The seid container's start gate only polls the sidecar's /v0/healthz (sidecarWaitCommand), not mark-ready, so seid execs within seconds of the pod starting — long before observe-image completes and this task runs — and it re-reads config.toml only on restart. The repo's own running-node config path does this correctly: buildConfigUpdatePlan uses config-patch, config-validate, mark-ready, restart-seid, with the comment that approval must precede restart so seid passes the healthz gate. Appending sidecar.TaskTypeRestartSeid here closes it; as written, a node reverted after previously having controller config keeps running the stale file indefinitely while the plan reports success (TestNodeConfigRevertRollsBeforeAnyConfigWrite pins mark-ready as the last task). (Raised by codex and confirmed.)
There was a problem hiding this comment.
Rejecting this one — the premise is wrong, and I checked it in the sidecar rather than reasoning from the progression.
/v0/healthz is gated on mark-ready. handleHealthz returns 503 unless engine.Healthz() (sidecar/server/server.go:101-107), Healthz() is return e.ready.Load() (sidecar/engine/engine.go:409-411), and the only writers of ready are the TaskMarkReady / TaskMarkNotReady arms at engine.go:399-403. TestLivezReturns200BeforeReady pins it: if eng.Healthz() { t.Fatal("expected healthz=false before mark-ready") } (sidecar/server/server_test.go:81-84).
So on a replacement pod the sidecar process is new, ready starts false, healthz answers 503, and the seid container sits in its wait loop. seid does not exec "within seconds of the pod starting" — it execs when mark-ready runs, which is the last task in this plan, after config-apply. restart-seid would be restarting a process that has not started.
That is also why buildConfigUpdatePlan needs restart-seid and this plan does not: that path has no pod replacement, so seid is already running and past its gate. This path replaces the pod, so the gate has not opened yet. The same reasoning is why every mode's NodeUpdate plan ends at mark-ready with no restart.
I have put that in the code comment so the next reader does not have to re-derive it.
Your framing did surface two real problems next to it, both fixed in 707e8d1 — see the replies on the Bugbot threads.
| // ContainerCreating and `kubectl describe pod` names the missing key. | ||
| // +kubebuilder:validation:MinLength=1 | ||
| // +kubebuilder:validation:MaxLength=253 | ||
| Name string `json:"name"` |
There was a problem hiding this comment.
suggestion — Name carries only length bounds, so a value that is not a DNS subdomain passes admission and then renders a StatefulSet the API server rejects, leaving apply-statefulset failing every reconcile. The sibling object-name fields in this file (DataVolumeImport.PVCName, volumeAttributesClassName) both pin the DNS pattern; adding it here rejects the typo at kubectl apply. (Raised by codex and confirmed.)
There was a problem hiding this comment.
Fixed in 707e8d1 — pinned to the same DNS-subdomain pattern the sibling object-name fields use. The field carried a pattern before the rename to configRef/appRef and I dropped it in the move.
| // It wraps the node's mode planner rather than replacing it, so the mode keeps | ||
| // its own Validate and its own init and bootstrap plans. Those plans are | ||
| // already safe: both production-pod progressions run through | ||
| // withoutMountedConfigWriters. What the wrapper owns is the Running arm, which |
There was a problem hiding this comment.
suggestion — This comment names withoutMountedConfigWriters, which does not exist, and its claim is wrong in a load-bearing way: only buildBasePlan runs the filter. The bootstrap and genesis-ceremony progressions (bootstrap.go:132, bootstrap.go:170) are never filtered — they are safe because Validate refuses those shapes. A reader adding a new non-base builder would take this comment as coverage it does not have.
There was a problem hiding this comment.
Both correct, fixed in 707e8d1. The name was stale after the rename to withoutManagedConfigTasks, and the coverage claim was worse than stale — it read as exhaustive when only buildBasePlan runs the filter. The comment now says what is actually true: the bootstrap and ceremony progressions are never filtered, Validate refuses those shapes, and mountedConfigWriterInPlan refuses any plan that slips through anyway.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 53aa878. Configure here.
… overlay Three review findings on the revert path. observe-image cleared status.currentNodeConfig before config-apply ran. A restore that failed after that point dropped the node off staticConfigPlanner with no drift left to rebuild the plan, settling it on `seid init` defaults — the outcome the revert exists to prevent. observe-image now stamps only a gained mount; the plan carries ClearsNodeConfig and the executor applies it on completion, mirroring how ConfigValuesHash is stamped. The revert plan assembled through assembleStaticUpdatePlan, which never calls withConfigValues, so a revert that also set configValues neither applied them nor established the observed baseline. It now finishes through withConfigValues, whose config-validate anchor the progression carries. ConfigFileRef.Name carried only length bounds, so a value that is not a DNS subdomain passed admission and then rendered a StatefulSet the API server rejects. It now pins the pattern its sibling object-name fields use. Also corrects a doc comment that named withoutMountedConfigWriters, which no longer exists, and claimed coverage of progressions the filter never sees. Reported by seidroid and Cursor Bugbot on #565. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review pass — 707e8d1Applied (4): the revert stamp now waits for the restore ( Rejected (1) — the blocker. test-integration is a pre-existing flake, and I have the control run
It failed on the first of four runs on unmodified main, with the identical message and the identical 121s timeout. The stall is Two non-blocking findings I have not actioned, and whySeiNetwork child sync. Real, and I would rather fix it deliberately than fold it in. Note the shape is narrower than the summary suggests: Render-time hold for the four refused shapes. Agreed, and it is the same lever as the unresolved-reference residual already documented in the field doc: Pre-existing |
Superseded: the latest review found nothing blocking in this change.
|
This revision completes the revert path: the plan now carries config-apply, the configValues overlay and config-validate ahead of mark-ready, a new TaskPlan.clearsNodeConfig defers the stamp clear to plan completion so a failure retries on the writer-exempt planner, and ConfigFileRef.Name gained the DNS pattern. Both of my blockers are addressed — I verified the author's start-gate claim in the sidecar (Engine.Healthz is e.ready, flipped only by mark-ready, and the seid container blocks on healthz 200), so seid does not read config before the write lands and no restart-seid is needed; codex contributed the name-validation finding, now fixed, and I again dropped its observe-image stamping claim, since the rollout gate cannot report complete for a pod at an older revision and the real consequence of that window is the indefinite poll I keep as pre-existing. Non-blocking
Pre-existingAlready true on the base branch, not introduced here.
seidroid review · decision Findings: 0 blocking | 2 non-blocking | 0 posted inline | 1 pre-existing |

What
A SeiNode can take its
config.tomlandapp.tomlverbatim from ConfigMaps:Both are required at admission and may name the same ConfigMap. They mount read-only over the seid config directory. The controller adds nothing and validates nothing beyond what it needs to avoid destroying a healthy pod.
Why the plan changes, not just the pod spec
A mount at the config path is not stable while anything else writes there. Tested on Linux 6.12.68: a rename onto a bind-mounted file from another mount namespace succeeds and detaches the mount —
is_local_mountpoint()is namespace-scoped andvfs_rename()callsdetach_mounts(). seid then reads the writer's file with nothing reporting the swap. A same-namespace rename does returnEBUSY, which is why the opposite conclusion looks right.Every sidecar writer of these files commits with
os.Rename. So a node withnodeConfiggets a plan with no config task in it:staticConfigPlannerwraps the mode planner, so each mode keeps its ownValidateand init plan.MountsNodeConfig— the union of spec and observed stamp — is the predicate everywhere, because a node mid-revert still has the mount.mountedConfigWriterInPlanis the single fail-closed gate.Clearing
nodeConfigrolls the pod and then writes the controller-managed base, so the node does not keepseid initdefaults.Rejected and refused
Rejected at admission, each because its only route to seid was a config task:
configValues,overrides,peers,externalAddress.peersis the sharpest —status.resolvedPeerswould keep looking correct whileconfig.tomlstayed as written. Putp2p.persistent-peersin the ConfigMap.Refused by
staticConfigPlanner.Validate, each because it learns config at run time:config.tomlthereThe bootstrap case would have been silent:
reconcileStatefulSetapplies unconditionally, so the production pod holds the mounts while the Job pod runsconfig-apply, and the claim isReadWriteOnce, notReadWriteOncePod.Notes for review
EBUSY. Removing it would turn every loud failure into a silent one.replace-podrefuses to delete into that, but a drain or eviction recreates the pod intoContainerCreating. ANodeConfigReadycondition plus a render-time hold is the fix, deferred.spec.nodeConfig.{configRef,appRef}.nameandstatus.currentNodeConfig. A thirdseiRefdrops in beside them when seid moves to a singlesei.toml.🤖 Generated with Claude Code