Harden persistent local volumes (issue #39) - #50
Draft
artemnikitin wants to merge 12 commits into
Draft
Conversation
…eation marker Address the first six findings of issue #39 against the merged local-volume slice. Item 3a/3c: fitStorage checked the local pool unconditionally, so once retained reservations on a node exceeded its configured capacity the node rejected every service — including services with no volumes, whose delta is zero. Combined with BuildNodeConfigs dropping pending services and the agent turning an absent service into ActionDelete, one oversized `size:` edit evicted a whole node. Guard the check on a positive local delta, and split the collapsed rejection reason into a placement fact (volume_capacity_unavailable) and a capacity fact (node_storage_exhausted). Item 7: overlapsVolumePath also matched when the volume was *below* a writable path, so declaring a volume at /var/lib/app/data disabled the chown of the declared writable path /var/lib/app. Match only at or below a volume root, and prune volume subtrees during the walk instead of skipping the parent. Item 11: mergeVolumeStatuses had no `<= 0` fallback for AppliedSizeBytes, so a volume that exists on disk displayed applied 0 whenever its VM was not running. Item 2: destructive filesystem commands ran on the agent's SIGTERM-cancelled context, and exec.CommandContext cancellation is SIGKILL. Split CommandRunner into Run and RunDestructive; the latter detaches the context, takes its own absolute timeout, and cancels with SIGTERM plus a WaitDelay. Item 10: pool gauges were published only from checkCapacity, which runs only when a service declares local volumes, so they vanished on a node holding retained-but-unplaced volumes. Observe the pool once per tick from the agent loop instead. Item 4: Start held the manager lock across volume preparation, so heartbeats blocked behind a multi-minute mkfs or resize and the node went stale while resizing its own services' volumes. Split Start into three phases with an explicit start barrier: a startID-stamped StateStarting placeholder, unlocked preparation, and a phase 3 that confirms ownership before any side effect. Stop and Remove abort an in-flight start without waiting. An aborted start is reported as incomplete rather than failed, so the tick neither advances lastRevision nor claims the revision as applied. Item 9: the kernel command-line length check lived only on the update path. Make writeVMConfig and Preflight share one boot-args builder. Item 6: a crash between the backing image and the manifest quarantined an empty volume forever. Write a creation marker first and remove it after the manifest; a matching marker authorizes re-creating the image. Every successful manifest read clears a surviving marker, so its delete authority cannot outlive the condition it records. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cover the tests issue #39 names as not optional (items 3, 6, and 7), plus the start barrier's races and revision-advancement contract. The item 4 revision test drives a full Agent tick under both update strategies rather than Manager.Start, because the defect it guards lives in the tick: a Manager.Start unit test passes either way. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Items 3b, 3c, and 5 of issue #39. applyExistingVolumeRecords raised DesiredSizeBytes and bumped the generation with no feasibility check, and storageReservations then counted the inflated value — which is how a node's reservations got above its pool in the first place. Node discovery is hoisted above the record work so capacity is in scope, and a raise that would push a bound node past LocalCapacityBytes is refused. Declining the record write alone would change nothing the agent sees: the scheduling copy still carries the requested SizeBytes and BuildNodeConfigs renders it regardless. So a refusal clamps the rendered configuration to the last accepted size and records the request durably, and the service keeps running. Marking it pending is not an option — pending drops the service and the agent turns that into a delete. The idempotence guard keys on RequestedSizeBytes, not DesiredSizeBytes: after a rejection DesiredSizeBytes holds the effective size, so comparing against it makes the unchanged request look new every tick. RejectedAt is preserved rather than restamped for the same reason. Raises are evaluated in name order against an evolving reservation map, so a batch cannot admit two raises that jointly exceed one pool, and every leader admits the same subset. loadVolumeRecords now partitions instead of returning on the first bad key. Partitioning alone would be unsafe — dropping a record silently releases its reservation — so each quarantine charges what it can prove and flags the scope it cannot: exact at tier 1, a lower bound plus an unknown-capacity block at tier 2, and a class-wide block at tier 3, which covers both classes when even the type is unreadable. An unrecognized resize_state is carried through rather than quarantined, so a newer record cannot brick an older controller. Blocking an owner does not evict it. A held service that is already placed is re-rendered from the previous placement revision at its last effective volume configuration, with its compute still reserved; one that was never placed is left pending. Quarantined outcomes enter volumeRecordsDigest so a repair actually invalidates the signature cache. Status carries three sizes now — requested, effective, applied — and fireworkctl and the web UI show requested next to effective, because an operator who edits size: and sees nothing change needs to be told why. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Item 8 of issue #39, plus the item-1 deferral. A shrink below the safe minimum was measured twice: once on a live filesystem before the VM was stopped, and again after deleteService had already stopped it. The second rejection therefore destroyed the property the preflight existed to provide — that a failed resize leaves the VM live — and neither rejection was terminal: needsUpdate compares whole volume configs, so a service brought back at its old size differed from the rendered desired config on the next tick and was stopped again, every reconcile interval. The fix makes a rejection a decision rather than a fault. inspectShrinkMinimum returns a typed ErrShrinkRejected carrying its LogicalID, so a clamp can tell which volume it applies to. prepareOne records the refusal in the volume manifest and returns the volume at its applied size with no error, so Prepare continues and one pass collects every rejection — a retry budget cannot survive a second rejected volume. Preflight accumulates the same way. Terminality does not depend on the control plane. The refusal is durable in the manifest, and the agent normalizes the desired node config through it before anything else in the tick reads it, substituting the effective size *and* generation: clamping only the size leaves the generation differing forever and re-plans the update on every tick. Start stores that same effective config on the instance, so the next tick compares equal rather than one cycle later. The advisory preflight rejection is terminal by the same mechanism. A live resize2fs -P errs in both directions, so a false rejection is possible — but it costs one re-request that mints a new generation and re-measures, while a non-terminal preflight costs an unbounded measurement loop forever. Preflight now writes the manifest, so its whole read/measure/write runs under the same per-volume lifecycle lock prepareOne takes; holding it across the measurement is what stops a concurrent Prepare from losing the update. An update that changes the image and requests a refused shrink still deploys the image, because the refusal clamps rather than blocks. On rejection the checking transaction is removed before the manifest is written. That order is crash-consistent: crashing after the removal loses only the record and the request is re-measured, while the reverse order leaves the stale transaction that quarantines the corrected retry. Removal is safe because the checking phase completes without moving filesystem geometry; every later phase has, and its transaction survives. Visibility does depend on the control plane. A rejection is reported as a third volume state carrying the requested generation — reporting it as prepared is dropped twice over, by the generation guard and by the prepared arm's size equality — and acknowledgeVolumeRecords converges the record on the effective size in one write. A rejection never goes through setVolumeError, and the blanket volume-error overwrite leaves a rejected volume alone. Item 1 is deferred to #49, whose first deliverable is the fc-init PID-1 supervisor redesign, and the caveat is published in docs/persistent-volumes.md. The direct-Git resize_generation contract is documented alongside it, with a configcheck --node-config warning for the shape a hand-authored file takes. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A refused size and an unreadable volume record both leave a healthy workload running, so neither is a failure — but the tick that follows one runs clean to the end, advancing the revision and marking it applied. Without a distinct signal the node reports ordinary convergence while running a size nobody asked for, which is the failure mode the rest of this work exists to prevent. The agent publishes a new non-blocking VolumeSizesApplied condition, false with reason volume_size_rejected while any volume is running at an effective size. Non-blocking is the right class: failing the node over a wrong quota would be a worse outcome than the quota. The control plane's degraded fleet reason is now derived from which non-blocking condition is false rather than hardcoded to peer routes, with a fixed precedence so it is stable. Held services — running at their last applied configuration because their own record could not be read — are carried in the placement revision and degrade the revision status. They are deliberately not pending: pending drops a service from the rendered node configs and the agent turns that into a delete. The heartbeat size bound is re-measured with the new per-volume fields at their limits, which moves the worst case from 12.2 to 13.6 MiB against a 16 MiB cap. Docs for fireworkctl and deployment visibility now carry the three-size volume table and the full storage reason-code vocabulary. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the conflict with PR #47 (node-exclusive host-port claims), which this branch's description flagged as the coordination point: both changes add a per-candidate rejection tracker to the same loop in ScheduleWithStorage. Textual resolution: keep both trackers. The port check stays ahead of fitStorage, as #47 placed it, so a port-rejected node still commits no storage usage, and the pending reason keeps #47's precedence — a host-port conflict outranks a storage reason, because the port check runs first and a node rejected on ports is never evaluated for storage, so a storage reason recorded elsewhere describes a different node than the one the operator has to fix. That precedence is now stated where the constant is defined rather than left implicit in statement order, and the literal is named ReasonHostPortConflict alongside the other reason codes. Semantic resolution: a held service — one re-rendered outside the scheduler because its own volume record could not be read — still holds its host ports, and the scheduler could not see them. Neither branch had this hole; only the combination does. The scheduler now takes the pinned claims explicitly, and the controller supplies them from the held set, the same way reserveHeldCapacity already reserves their compute. Without it the scheduler would hand a held service's port to a newcomer on the same node, the agent would reject the whole rendered node config, and an unreadable record for one service would take down every service on that node — strictly worse than the hold it came from. The two docs that both branches touch are reconciled rather than concatenated: the pending-reason example list now carries the storage vocabulary as well as host_port_conflict, and the two reason vocabularies cross-link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five reproduce; each fix is covered by a test that fails without it. 1. An acknowledged shrink rejection restarted the VM, repeatedly. Once the control plane acknowledges a refusal it renders the effective size with the *refused* generation — it keeps that generation so the acknowledgement can match its own record. Normalization recognized only the refused *size*, so the rendered generation stayed different from the running instance's applied one, needsUpdate compared unequal, and the service was stopped and restarted on every reconcile that reached Plan: once per revision for a single-label agent, every poll for a multi-label one, which bypasses the unchanged-revision shortcut. This is the loop item 8 exists to end, in a new form. matchesRejection now recognizes both shapes a refused request takes — the refused size and the applied size — at the refused generation. A corrected direct-Git size at an unchanged generation still re-measures, because it matches neither. 2. A placement read failure could evict a service whose volume record is quarantined. With the previous placement unreadable, a held service was classified as never-placed and left pending, which drops it from the rendered node configs — and the agent turns an absent service into a delete. That contradicts §4.4.2's own rule that a held service is never omitted, because omission is eviction. There is no partial answer to give, so the cycle now publishes nothing and the next tick retries. With nothing held, a failed read stays harmless. 3. The rejection snapshot was not reconciled as a snapshot: refreshRejections only visited the volumes a Prepare happened to touch. After an agent restart it stayed empty, and if normalization clamped the config so no action was planned nothing would ever repopulate it — the node reported every size applied while running an effective one. A removed volume's entry was never pruned either, leaving VolumeSizesApplied false indefinitely. Normalization now rebuilds the whole snapshot from the durable manifests of the desired set, once per tick. 4. A shrink could not recover a pool already over capacity. Admission ran for every size change and reserves max(requested, applied), so a shrink leaves the contribution unchanged until it applies — and checking that against a pool reconfigured smaller refused the one operation that would restore it. A request that does not increase the contribution is not subject to admission at all. 5. An unknown future resize state was overwritten during rollback. The parse tolerates an unrecognized state so a newer record cannot brick an older controller, but admission still reset it to pending and advanced the generation, destroying the state that tolerance exists to preserve. Such a record is now rendered as it stands and never rewritten. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four reproduce; each fix has a test that fails without it. 1. A missing placement object bypassed the eviction guard. readExistingPlacement returns (nil, nil) when the pointer or the revision object does not exist, and the guard keyed on the error alone — so a pointer naming a revision whose object is gone read as "nothing has been placed yet", and a held running service was classified never-placed and dropped from the rendered configs. The read now reports whether the placement was established at all, and the guard keys on that. An absent pointer reports the same way; the only caller that consults it also requires a held service, which requires a retained volume record, which a cluster that has never placed anything does not have. 2. The clamp kept reporting a refusal after the request was withdrawn. Matching the applied size at the refused generation fixed the restart loop, but those are the same bytes a direct-Git operator writes to *withdraw* a request — so the refusal was reported forever and the node stayed degraded with no exit but a generation bump. This is not direct-Git-only: reverting size: in controller-managed mode clears the record's rejection while the controller goes on rendering that identical shape. Clamping and reporting are now separate. The clamp still applies to both shapes, so the generation cannot diverge from the running instance; a *report* of a standing refusal requires the refused size to actually be requested. Once the shapes become identical the agent genuinely cannot tell a standing request from a withdrawn one, so that half of the visibility moves to the control plane, which has the record: a standing record refusal now degrades the revision status. This is the division §7.3.2 of the plan already draws — terminality does not depend on the control plane, visibility does. 3. configcheck --node-config parsed but never validated, reporting OK for a config with no node name, no image or kernel, zero compute, and a negative volume size. It now runs the same semantic validation the control plane applies before rendering. 4. An absent resize_state was treated as a future protocol's state. Forward compatibility exists to protect a value a newer controller deliberately wrote; the empty string is what a truncated or hand-edited object produces, and preserving it froze the record forever with nothing to name as its owner. It is quarantined as malformed; only a non-empty unknown value gets forward-compatibility treatment. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snapshot is maintained by two passes with different jobs — rebuild decides against the raw config once per tick, refresh only adds a refusal it just discovered or drops one the manifest no longer has. The asymmetry is what keeps a standing refusal alive after the clamp has erased the refused size from the config, and it is easy to break by making either pass authoritative. This walks a refusal through all of them: recorded in the tick it happens, surviving both passes on later ticks, and cleared in the same tick a resize applies.
A refused size is always strictly below the applied size, because both refusal sites reach the measurement only on a shrink. That is what makes matchesRejection's two arms disjoint, and therefore what makes a withdrawn request distinguishable from a standing one. The dependency is implicit in the guards today, so a later change permitting a refusal on the grow path would collide the two arms and silently degrade the node forever.
All three reproduce; each fix has a test that fails without it. 1. A refusal on a deleted service degraded every later revision. Records outlive their service by design — deleting application YAML never deletes a volume or its record — so scanning every retained record meant one refused resize followed by a service deletion left the empty deployment, and everything after it, permanently degraded with no service left to repair. A refusal is only a convergence problem while something is still asking for the size, so the revision status considers only volumes the desired revision declares. 2. Withdrawing a refusal left the record self-contradictory. Clearing the rejection fields while ResizeState stayed "rejected" published state "rejected" alongside rejected:false, and nothing repaired it: the agent reports the applied generation once the config is normalized, while acknowledgement only matches an observation at the refused generation, so the record was never revisited. Ending a refusal now resets the state — to applied or pending, per what is on disk — and drops the message that described it. 3. configcheck --node-config validated generic service fields but not the volume contract, accepting a local volume with no bound_node that the agent then refuses to start. It now runs the agent's own rules rather than a restatement of them, because a second copy drifts and the failure mode of drift here is a config that passes CI and cannot start on the node. A bound_node naming a different node than the config is for warns rather than fails: the agent matches its stable node_id, which need not equal the config key, so a mismatch is only probably wrong. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five reproduce; each in-repo fix has a test that fails without it. 1. Retained manifests with a non-positive applied size bypassed pool capacity. readRetained fed AppliedSizeBytes straight into the reserved total, and a negative one *subtracts* — a single corrupt or hand-edited manifest admitted a 150 MiB volume into a 100 MiB pool. There is no safe number to assume for such a record, so it fails closed, and the summation additionally rejects a non-positive size from any source: a total that can be driven downwards is a capacity bypass whatever produced it. 2. A stale heartbeat reopened a withdrawn refusal. acknowledgeVolumeRecords matched on the generation alone, but a record whose refusal was withdrawn sits at that same generation — so a stale observation set it back to rejected, the next desired-state pass cleared it again, and the pair repeated every tick. Two durable writes per tick is bad; a crash between them leaves the degraded state behind. A rejected observation is now accepted only while the refusal is still outstanding. 3. An explicit zero-volume prior render was mistaken for a missing snapshot. Substituting the desired configuration there rendered exactly the unvalidated volume config the hold exists to gate. Zero volumes is valid prior state; a genuinely missing snapshot cannot reach that code at all, because heldPlacementUnrecoverable stops the cycle before anything is published. 4. ValidateNodeVolumes checked volume declarations but not the service name, which the agent also turns into a path component — so a service named "bad/name" passed configcheck and failed agent preflight. It now goes through volumeDir, the same function the agent uses, rather than restating the pattern. The fifth finding cannot be fixed here. Detaching a destructive command from the Go context does not escape the agent's systemd cgroup: under the default KillMode=control-group, stopping the unit signals the detached mkfs or resize2fs directly and force-kills it at TimeoutStopSec. The required supervision contract — KillMode=mixed and a TimeoutStopSec above destructiveCommandTimeout — is now documented in docs/persistent-volumes.md and on the constant itself, and implemented in artemnikitin/firework-deployment-example#24. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the eleven code-review findings in #39 against the merged local-volume slice, following the persistent-volume hardening plan. Item 1 is deferred to #49 with the required documented caveat; the other ten are fixed here.
The plan sequences this as six PRs; per the issue they are combined into one. Within the diff the ordering that mattered was kept: the three-phase
Startlands before the shrink clamp that sits between its phases, and the record schema lands before the state that uses it.What each item does
Item 3 — a config edit could evict a whole node.
fitStoragechecked the local pool unconditionally, so once retained reservations exceeded a node's configured capacity that node rejected every service — including services with no volumes, whose delta is zero.BuildNodeConfigsthen dropped them and the agent turned each absent service intoActionDelete. The check is now guarded on a positive local delta, because a volume already counted in the reservations cannot recover capacity by being rejected — only evict. The collapsed pending reason is split into a placement fact (volume_capacity_unavailable) and a capacity fact (node_storage_exhausted), which have opposite remedies during an incident.Item 3b — an infeasible size is refused instead of persisted. Node discovery is hoisted above the record work so capacity is in scope. Declining the record write alone would change nothing the agent sees — the scheduling copy still carries the requested
SizeBytesand renders it regardless — so a refusal clamps the rendered configuration to the last accepted size and records the request durably. The service keeps running. Marking it pending is not an option: pending drops the service and the agent turns that into a delete.The idempotence guard keys on
RequestedSizeBytes, notDesiredSizeBytes: after a rejectionDesiredSizeBytesholds the effective size, so comparing against it makes the unchanged request look new on every tick and mints a generation forever.RejectedAtis preserved rather than restamped for the same reason. Raises are evaluated in name order against an evolving reservation map, so a batch cannot admit two raises that jointly exceed one pool, and every leader admits the same subset.Item 5 — one bad record no longer stops cluster scheduling.
loadVolumeRecordspartitions instead of returning on the first bad key. Partitioning alone would be unsafe — dropping a record silently releases its reservation and turns a hard failure into over-commit — so each quarantine charges what it can prove and flags the scope it cannot: exact at tier 1, a lower bound plus an unknown-capacity block at tier 2, and a class-wide block at tier 3, covering both classes when even the type is unreadable (the key encodes only service and volume names). An unrecognizedresize_stateis carried through rather than quarantined, so a record written by a newer control plane cannot brick an older one during a rollback.Blocking an owner does not evict it. A held service that is already placed is re-rendered from the previous placement revision at its last effective volume configuration, with its compute still reserved; one that was never placed is left pending. Quarantined outcomes enter
volumeRecordsDigest— hashing the normalized outcome, excluding the display reason — so a repair actually invalidates the signature cache.Item 2 — destructive commands are no longer SIGKILL-able by a node drain.
CommandRunnersplits intoRunandRunDestructive; the latter detaches the context, takes its own absolute timeout, and cancels with SIGTERM plus aWaitDelay. The distinction lives in the interface, not a name match, so a new destructive command cannot inherit the cancellable path by omission.Item 6 — an interrupted first creation is recoverable. A creation marker is written before the backing image and removed after the manifest; a matching marker authorizes re-creating the image, and an absent, unreadable, or mismatched one still quarantines. Every successful manifest read clears a surviving marker, so its delete authority cannot outlive the condition it records — otherwise a later manifest loss would let it authorize destroying populated data.
Item 9 — the command-line length check reaches the create path.
writeVMConfigandPreflightnow share one boot-args builder.Item 7 — a writable path above a volume mount is chowned again.
overlapsVolumePathalso matched when the volume was below the path, so declaring a volume at/var/lib/app/datadisabled the chown of/var/lib/app. It now matches only at or below a volume root, and the walk prunes volume subtrees rather than skipping the parent.Item 4 — a long volume operation no longer makes the node look dead.
Startheld the manager lock across volume preparation, and the heartbeat goroutine reaches the same lock throughList, so the node went stale precisely while it was busy resizing its own services' volumes.Startis split into three phases with an explicit start barrier: astartID-stampedStateStartingplaceholder, unlocked preparation, and a phase 3 that confirms ownership before any side effect.StopandRemoveabort an in-flight start and return immediately rather than waiting out anmkfs. An aborted start is reported as incomplete rather than failed, so the tick neither advanceslastRevisionnor claims the revision applied — otherwise the next tick takes the unchanged-revision shortcut and the service stays down until the revision changes.Item 8 — a refused resize is terminal instead of a stop/restart loop. A rejection is now a decision rather than a fault:
prepareOnerecords it in the manifest and returns the volume at its applied size with no error, so one pass collects every refusal (a retry budget cannot survive a second rejected volume). Terminality does not depend on the control plane — the agent normalizes the desired node config through the durable manifests before anything else in the tick reads it. The advisory preflight rejection is terminal by the same mechanism, and its whole read/measure/write now runs under the same per-volume lifecycle lockprepareOnetakes. On rejection the checking transaction is removed before the manifest is written, so a crash between them loses only the record rather than leaving the stale transaction that quarantines the corrected retry.Visibility does depend on the control plane: a rejection is reported as a third volume state carrying the requested generation, and
acknowledgeVolumeRecordsconverges the record on the effective size in one write.Items 10 and 11. Pool gauges are published once per tick from retained state rather than from the admission path, so they no longer vanish on a node holding retained-but-unplaced volumes.
mergeVolumeStatusesgains the missing<= 0fallback forAppliedSizeBytes, plus fallbacks for the three control-plane-sourced rejection fields — the agent is handed the clamped config and does not know a rejection happened, so whole-struct replacement would clear them every cycle.Convergence signal. A standing rejection and a held service both leave a healthy workload running, so neither fails the node — but neither is convergence. A new non-blocking
VolumeSizesAppliedcondition degrades the node, and held services degrade the revision status. Without this the node reports ready while running a size nobody asked for.Deliberate divergences from the plan
RejectedReasonrather thanResizeState. §4.2.2 saysResizeStatemust not carry it (it describes the last resize the agent attempted), while §4.2.4's guard text keys onVolumeResizeRejected; the two cannot both hold.VolumeResizeRejectedis retained for the agent-reported shrink rejection, which did enter that state machine.needsUpdatecompares wholeVolumeConfigstructs, so a size-only clamp leaves the generation differing forever and re-plans the update on every tick. The refused generation travels separately, in the rejection record, which is what the acknowledgement matches on.Rollback
Rolling the agent back below this change re-opens the resize loop. An older agent ignores the manifest's rejection fields and re-attempts the refused configuration until the controller's clamp reaches it — and if the rollback also predates the controller change, indefinitely. Roll the agent back only after confirming the controller is rendering effective sizes; otherwise stop the affected services first.
cmd/fc-initships a new guest binary, so a service that declares a volume under a writable path stays broken until its guest image is rebuilt on both architectures.Coordination
PR #47 (node-exclusive host-port claims) merged as
30e6295and is now merged into this branch. The two changes interacted in two places:ScheduleWithStorage. Both are kept; the port check stays ahead offitStorageas Make host ports node-exclusive placement claims #47 placed it, so a port-rejected node commits no storage usage, and a host-port conflict outranks a storage reason in the pending vocabulary — the port check runs first, so a node rejected on ports is never evaluated for storage and any storage reason recorded elsewhere describes a different node than the one the operator has to fix. That precedence is now stated where the constant is defined rather than left implicit in statement order.ScheduleWithStoragenow takes the pinned claims explicitly and the controller supplies them from the held set, the same wayreserveHeldCapacityalready reserves their compute. Without it the scheduler would hand a held service's port to a newcomer on the same node, the agent would reject the whole rendered node config, and an unreadable record for one service would take down every service on that node.Review rounds
Four review rounds against this branch found seventeen defects, all reproduced with focused probes and all fixed, each with a test that fails without its fix — sixteen here, and one that cannot be fixed here at all (see below). Every one of the seventeen passed
go test ./..., targeted race tests, and all eight CI checks — none was reachable from the paths this branch's own tests exercised, which is worth knowing when weighing the validation below.Round one (five): an acknowledged shrink rejection restarted the VM on every reconcile; a placement read failure could evict a held service; the rejection snapshot was never reconciled as a whole; capacity admission was applied to shrinks, refusing the one operation that recovers an over-capacity pool; and admission rewrote an unknown
resize_state.Round two (four): a missing placement object bypassed the eviction guard, which keyed on the read error alone; the clamp kept reporting a refusal after the request was withdrawn — not direct-Git-only, since reverting
size:under a controller clears the record while the controller renders the identical shape;configcheck --node-configparsed without validating; and an absentresize_statewas treated as a future protocol's rather than as malformed.Round three (three): a refusal on a deleted service degraded every later revision, because retained records outlive their service and the signal was not filtered by the desired revision; withdrawing a refusal cleared its fields but left
ResizeState: rejected, publishingstate: rejectedbesiderejected: falsewith no path left to repair it; andconfigcheck --node-configvalidated generic service fields but not the volume contract, accepting a local volume with nobound_nodethat the agent then refuses to start.Round four (five): retained manifests with a non-positive applied size bypassed pool capacity, because a negative size subtracts from the reserved total; a stale heartbeat reopened a withdrawn refusal, producing two durable writes per tick; an explicit zero-volume prior render was mistaken for a missing snapshot and replaced with the unvalidated desired config;
ValidateNodeVolumeschecked volume names but not the service name the agent also turns into a path component; and — the one that is not a code defect here — item 2's design was incomplete.Item 2 needs a change in another repository
RunDestructivedetaches a filesystem utility from Go context cancellation, and §5.1 of the plan treated that as closing the window where "a systemd restart, deploy, or node drain during a shrink SIGKILLsresize2fsmid-operation". It does not. Detaching from a Go context does not detach a child from the agent's cgroup: under systemd's defaultKillMode=control-group, stopping the unit signals every process in it — the detachedresize2fsincluded — and force-kills the group atTimeoutStopSec. The named scenario was still unprotected.The unit must set
KillMode=mixedand aTimeoutStopSecabovedestructiveCommandTimeout. Those two numbers are a cross-repository contract; raising either alone reopens the gap. This PR documents it indocs/persistent-volumes.mdand on the constant; the unit change is firework-deployment-example#24, and item 2 is not complete until both land.It also promotes one validation gap: the live SIGTERM-during-shrink test is now a release gate for item 2 rather than missing confidence, because the protection spans two repositories and cannot be verified in either alone.
The second round's central fix is a design correction worth calling out: clamping and reporting a refusal are different questions and must be keyed differently. The clamp applies for as long as the refused generation stands, or the generation diverges from the running instance and the service restarts. A report of a standing refusal requires the refused size to actually be requested — and once the control plane acknowledges, the two shapes are identical bytes, so only the record can tell a standing request from a withdrawn one. A standing record refusal therefore degrades the revision status. That is the division §7.3.2 of the plan already draws: terminality does not depend on the control plane, visibility does.
Round three added two rules the plan never stated at all, now written into §12.2.1: a retained record is not evidence that anything still wants what it describes, so any convergence signal derived from retained state must be filtered by the desired revision; and a state machine's exit must be as complete as its entry, because clearing a terminal state's detail fields while leaving the state itself publishes a contradiction that no later path revisits.
Validation
make fmt,make test,make test-race, andmake lintare green.cmd/fc-initis//go:build linux, so its tests were run in a linux container rather than on the development host. The privileged end-to-end paths — an agent SIGTERM during a live shrink, a multi-minutePrepareagainst a real heartbeat, and the guest chown on both architectures — need the #42 validation lab and were not executed.Refs #39, #19, #49