diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9defd4c..d0f1b13 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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 } diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 54be3ea..5402d54 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -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) + } +} diff --git a/internal/profile/detectors.go b/internal/profile/detectors.go index aedfde2..0f461ba 100644 --- a/internal/profile/detectors.go +++ b/internal/profile/detectors.go @@ -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..>` 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{}, diff --git a/internal/profile/reads_wants_test.go b/internal/profile/reads_wants_test.go new file mode 100644 index 0000000..fb77378 --- /dev/null +++ b/internal/profile/reads_wants_test.go @@ -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) + } +}