The node-engine reports reads-wants; a failed apply is said; the have's fingerprint and digest follow its health (R6-A A6)

This commit is contained in:
2026-10-24 16:43:26 +02:00
parent b2003e7a38
commit 96d0322498
4 changed files with 80 additions and 0 deletions
+1
View File
@@ -491,6 +491,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
if res.Failed {
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev}
a.mu.Unlock()
a.env.Say(fmt.Sprintf("applying %s at %s failed: %s", module, fp, res.Detail))
a.env.Haves.Write(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: res.Detail})
return
}
+37
View File
@@ -461,3 +461,40 @@ func TestTheFirstHaveWrittenIsToldToTheProof(t *testing.T) {
t.Fatal("a have already saying the same was not told")
}
}
// R6-A AC6: an applied module's have names its fingerprint and the digest of the composition applied; a health check that
// fails after the module's start window gives health failed, and the have keeps both.
func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth(t *testing.T) {
l := newHaveLink()
q, _ := aHaveQueue(l)
fp := "src2:" + strings.Repeat("a", 64)
dig := "sha256:" + strings.Repeat("b", 64)
q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig})
h := l.have(t, "n1.web")
if h.FP != fp || h.Composed != dig || h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthUnknown {
t.Fatalf("the have after the apply: %+v", h)
}
// Starting: it waits. Past its start window the check fails: failed.
q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateStarting}))
if got := l.have(t, "n1.web"); got.Health != delivery.HealthWaits || got.FP != fp || got.Composed != dig {
t.Fatalf("while it starts: %+v", got)
}
q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateUnhealthy}))
if got := l.have(t, "n1.web"); got.Health != delivery.HealthFailed || got.FP != fp || got.Composed != dig {
t.Fatalf("a failing check after the start window: %+v", got)
}
// A module held for a failed backup says so and what it keeps running; removed is written once, over its key.
q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Phase: string(delivery.PhaseHeld), Detail: "the repository is locked"})
if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseHeld || got.Detail == "" {
t.Fatalf("held: %+v", got)
}
q.NoteRemoved(context.Background(), "web", "")
if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseRemoved {
t.Fatalf("removed: %+v", got)
}
q.NoteNode(context.Background(), dig)
if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Composed != dig {
t.Fatalf("the node key: %+v %v", n, err)
}
}
+20
View File
@@ -40,6 +40,11 @@ const (
// controller sends the field only to a node that reports it, since an older node-engine refuses the key.
CapContainerGroups = "container-groups"
// CapReadsWants says this node-engine follows its wants (novox/hq ADR 0539 §9): it watches `want.<node>.>` and its
// compositions and applies one module at a time, and hears no declaration. A fact about the program: the controller
// stops sending a node declarations only once it reports it.
CapReadsWants = "reads-wants"
// The network manager this machine runs, one capability per dialect (novox/hq ADR 0161): the
// uplink seat's holder declares its own, so the holder for a manager the machine does not run
// is refused the way any missing capability is, naming it. Active, not installed — a machine
@@ -190,6 +195,20 @@ func (containerGroups) Detect(context.Context) Verdict {
}
}
// readsWants reports that this node-engine follows its wants. True of the program that is running, so it is always
// present: its absence on a node is what tells the controller the node-engine still waits for declarations.
type readsWants struct{}
func (readsWants) Name() string { return CapReadsWants }
func (readsWants) Detect(context.Context) Verdict {
return Verdict{
Name: CapReadsWants, Present: true,
Detail: "follows its wants and applies one module's composition at a time; hears no declaration",
How: "a fact about this program, not probed: the node-engine that answers follows its wants",
}
}
// graphicalSession reports whether anything could display a window here.
//
// Environment rather than a probe, because a display server is reachable through a socket a
@@ -230,6 +249,7 @@ func Default(runner Runner) []Detector {
privileged{},
fileReferences{},
containerGroups{},
readsWants{},
graphicalSession{},
seat{},
battery{},
+22
View File
@@ -0,0 +1,22 @@
package profile
import (
"context"
"testing"
"time"
)
// R6-A: the node's profile carries the capability that says the node-engine follows its wants, which the controller
// checks before it stops sending the node declarations (novox/hq ADR 0539 §9).
func TestTheProfileSaysTheNodeEngineReadsItsWants(t *testing.T) {
got := Detect(context.Background(), Default(func(context.Context, string, ...string) (string, error) {
return "", nil
}), time.Second)
v := verdictOf(t, got, CapReadsWants)
if !v.Present || v.Detail == "" || v.How == "" {
t.Errorf("reads-wants = %+v, want present with a detail and a method", v)
}
if CapReadsWants != "reads-wants" {
t.Errorf("the capability is named %q", CapReadsWants)
}
}