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:
@@ -0,0 +1,58 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// One apply at a time on a machine, whoever asks.
|
||||
//
|
||||
// Three things apply here and each reads the state, acts, and writes the state back: the link
|
||||
// and the reconcile loop inside `mesh-host run`, the host's own `reconcile` and `apply` run by
|
||||
// hand, and the installer at genesis. Inside one process a mutex serialises them; across
|
||||
// processes nothing did, and two applies interleaved leave the last saver writing a state read
|
||||
// before the other acted — a hold, a firewall record, a resource just applied, lost (novox/hq
|
||||
// issue 104 review). A lock on a file beside the state is what every one of them can take, however
|
||||
// it was started: a `reconcile` run against a service, or against a `mesh-host run` somebody
|
||||
// started by hand, waits the same way. Refusing while a named service is active would have known
|
||||
// the service's name and missed the hand-started one.
|
||||
//
|
||||
// An advisory lock, held for the life of the open file and released by the kernel when the
|
||||
// process ends, so a host that dies mid-apply leaves no lock behind for the next one to clear.
|
||||
|
||||
// LockName is the file the lock is taken on, beside the state.
|
||||
const LockName = "state.lock"
|
||||
|
||||
// Lock takes the machine's apply lock and returns what releases it. When another process holds
|
||||
// it, wait is told once — so a person running a command knows what they are waiting for — and
|
||||
// Lock blocks until it is free.
|
||||
func Lock(statePath string, wait func()) (func(), error) {
|
||||
dir := filepath.Dir(statePath)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, LockName), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot take this node's apply lock: %w", err)
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
if !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("cannot take this node's apply lock: %w", err)
|
||||
}
|
||||
if wait != nil {
|
||||
wait()
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("waiting for this node's apply lock: %w", err)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defends the node's record across processes: a `reconcile` run by hand beside the link service,
|
||||
// or the installer beside either, waits for the other's apply rather than saving over it.
|
||||
func TestASecondApplyWaitsForTheFirst(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "state.json")
|
||||
release, err := Lock(state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
waited := make(chan struct{}, 1)
|
||||
got := make(chan struct{})
|
||||
go func() {
|
||||
unlock, err := Lock(state, func() { waited <- struct{}{} })
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
close(got)
|
||||
unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waited:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the second apply was not told it is waiting")
|
||||
}
|
||||
select {
|
||||
case <-got:
|
||||
t.Fatal("the second apply took the lock while the first held it")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
select {
|
||||
case <-got:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the second apply never got the lock once the first let go")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user