Refuse every file once the mesh has spoken, plan the cutover as one, and let the kept declaration repair the mode

Review of the fix for hq issue 104 found three faults in it. A file applied
on an enrolled node — the mesh's own last declaration included — is applied
as the bundle is, so its resources are recorded as the machine's own and
what the mesh declared reads as undeclared: the plan removed the foundation.
`apply FILE` is for a machine the mesh has not spoken to, and is now refused
saying so whenever declared.json exists. The plan looked at what is held
before what the declaration says is taken, so the one cutover ADR 0100 says
must be previewed read as a hold; it now decides in holdOnAdopted's order,
models a step run inside a held container, and a test holds the plan's
sequence to the apply's outcomes. Genesis wrote the mode on every run, so a
re-run after `converge` left the state saying adopted while the kept,
signed declaration said converged, and the reconcile loop refused every five
minutes with no delivery coming to end it: genesis now writes the mode only
when none is recorded, and where the state and the verified kept declaration
disagree, the kept declaration wins and the repair is said.

Also: a file lock beside the state, taken by the link service, the host's
own commands and the installer alike, so a `reconcile` run by hand no
longer races the loop's save — chosen over refusing while a named service is
active, which would miss a `mesh-host run` started by hand; `--json
--dry-run` emits {plan} like an apply emits {plan, report}; the README's
duplicate flag line; and the bundle refusal is about the digest, not a claim
the carried bytes can never match what genesis applied.
This commit is contained in:
2026-09-23 23:35:49 +02:00
parent 27c4b765b2
commit f08a8ea3f7
9 changed files with 441 additions and 74 deletions
+20 -9
View File
@@ -37,10 +37,11 @@ type Runner = apply.Runner
//
// What it does record is that the bundle was consumed, and in which mode the operator raised
// the machine. `raw` is the exact bytes of what is applied — the bundle as rewritten for this
// machine, not as carried — and its digest is what `mesh-host reconcile` later holds the carried
// bundle against: what genesis applied had its ports, root credentials and adoption rewritten,
// so the carried bytes are never it, and applying them on a raised node recreated the store and
// loaded the converged filter on an adopted one (novox/hq issue 104).
// machine — and its digest is what `mesh-host reconcile` later holds the carried bundle against,
// by digest: a host built from the carried template does not match it, since genesis rewrote the
// ports, root credentials and adoption; a host built from the lock genesis wrote out does.
// Applying the unrewritten template on a raised node recreated the store and loaded the converged
// filter on an adopted one (novox/hq issue 104).
func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declaration.Declaration,
raw []byte, run Runner, say func(string)) (apply.Report, error) {
@@ -50,6 +51,12 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati
return apply.Report{}, err
}
// One apply at a time on this machine: a host already running here applies too.
unlock, err := store.Lock(o.State, func() { say(" waiting another apply holds this node's state") })
if err != nil {
return apply.Report{}, err
}
defer unlock()
known, err := store.Load(o.State)
if err != nil {
return apply.Report{}, err
@@ -65,12 +72,16 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati
// The bundle is consumed, whichever way the apply went: what is on the machine came from these
// bytes, and the carried ones must not be applied over it. The mode is the operator's word at
// genesis; the controller records the same and says it in every declaration from then on
// (novox/hq ADR 0100).
// genesis, and only at genesis: the controller records the same and says it in every
// declaration from then on (novox/hq ADR 0100), so a node the mesh has spoken to — this
// installer re-run on it, or finishing a pivot — keeps the mode it has, which may since have
// been flipped.
updated.Genesis = &store.Genesis{Digest: apply.DigestOf(raw), At: time.Now().UTC(), Rewritten: true}
updated.Mode = store.ModeConverged
if o.Adopted {
updated.Mode = store.ModeAdopted
if updated.Mode == "" {
updated.Mode = store.ModeConverged
if o.Adopted {
updated.Mode = store.ModeAdopted
}
}
// Saved whichever way it went, for the reason `mesh-host` gives: what was applied before a