From 2779f5312b6b57c20d0569916db570b2cfc75dd3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:41:11 +0100 Subject: [PATCH 01/73] ENG-2: the node's record says the capabilities its tool runner states of its calls, asked of the runner's PING at each report (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/compose/resolution/resolve_test.go | 12 +++++++ internal/link/have.go | 4 ++- internal/link/have_test.go | 29 +++++++++++++++- internal/link/record.go | 9 +++++ internal/link/session.go | 33 ++++++++++++++++++ internal/link/witnessing.go | 37 +++++++++++++++++---- 6 files changed, 115 insertions(+), 9 deletions(-) diff --git a/internal/compose/resolution/resolve_test.go b/internal/compose/resolution/resolve_test.go index d6f0d4b..7eb06e4 100644 --- a/internal/compose/resolution/resolve_test.go +++ b/internal/compose/resolution/resolve_test.go @@ -157,6 +157,18 @@ func TestADirectlyAssignedModuleTheMachineCannotHostIsReportedNotRefused(t *test if !strings.Contains(WrongMachine("xorg", "seat", "server"), "wrong machine") { t.Errorf("the report reads like a missing module") } + + // And a module whose calls only a tool runner stating them answers (ENG-2, issue 1966) is held off a machine whose record + // does not say them, naming the capability; on one that does, it is hosted. + withCalls := mcat.Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true, "ledger-calls": true}} + ledgerOwner := shelf(mod("records", nil, nil, []string{"ledger-calls"})) + held, err := Resolve(ledgerOwner, []string{"records"}, server, mcat.World{}) + if err != nil || len(held.Unhostable) != 1 || len(held.Unhostable[0].Missing) != 1 || held.Unhostable[0].Missing[0] != "ledger-calls" { + t.Errorf("a ledger owner on a machine whose runner states no ledger calls: %+v, %v", held.Unhostable, err) + } + if got, err := Resolve(ledgerOwner, []string{"records"}, withCalls, mcat.World{}); err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 1 { + t.Errorf("a ledger owner on a machine whose runner states them was held: %v %v", got.Unhostable, err) + } } func TestAnUnhostableModuleSomethingRequiresRefusesTheNode(t *testing.T) { diff --git a/internal/link/have.go b/internal/link/have.go index eb13fb0..7f1a2ed 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -296,7 +296,9 @@ func (q *Session) noteApply(r Report) bool { func (q *Session) nodeFactsWithRecord(r Report, before *delivery.HaveNode) *delivery.HaveNode { n := nodeFacts(r) if q.Keys != nil { - n.Record = recordOf(q.Membership.Node, r, q.Keys()) + keys := q.Keys() + keys.Stated = q.runnerStated + n.Record = recordOf(q.Membership.Node, r, keys) } if before != nil && before.Record != nil { switch { diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 2fc4c41..f0825b2 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "sync" "testing" @@ -31,6 +32,15 @@ type haveLink struct { bindFails, bindStuck bool // bindNoResponders fails that many binds with the bus's no responders, as while it reloads its accounts. bindNoResponders int + // stated is what the tool runner's PING says of itself (Stater); statedErr is the runner not answering. + stated map[string]string + statedErr error +} + +func (l *haveLink) Stated(context.Context, string, string) (map[string]string, error) { + l.hmu.Lock() + defer l.hmu.Unlock() + return l.stated, l.statedErr } type haveEntry struct { @@ -259,6 +269,7 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { keys := RecordKeys{TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", Certificate: "cert", Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}} // and what it trusts (L3 s6, ADR 0550 §2) q.Keys = func() RecordKeys { return keys } + l.stated = map[string]string{CapabilitiesKey: "ledger-calls, bus-state-calls"} // the tool runner's calls (ENG-2) rep.Profile = map[string]any{"capabilities": []any{map[string]any{"name": "systemd", "present": true}, map[string]any{"name": "docker", "present": false}, map[string]any{"name": "apt", "present": true}}, "machine": map[string]any{"networks": []any{map[string]any{"link": "eth0", "network": "192.0.2.0/24"}}}} @@ -269,7 +280,7 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { t.Fatalf("the node key holds no record: %+v, %v", got, err) } wantRecord := delivery.NodeRecord{V: delivery.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", - Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, + Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "bus-state-calls", "ledger-calls", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract, ManifestReads: catalogue.ManifestReads()} // what it reads of a manifest: its contract's (ADR 0614 §4) if !delivery.SameNodeRecord(*got.Record, wantRecord) { @@ -280,6 +291,22 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { if l.kv["n1.node"].rev != at { t.Errorf("a report changing nothing wrote the node key again") } + // A runner that does not answer states what it stated before; one answering without any states none (an older runner). + l.statedErr = fmt.Errorf("%w: nothing answered", ErrNoAnswer) + l.stated = nil + q.SayFacts(context.Background(), rep) + if l.kv["n1.node"].rev != at { + t.Errorf("a runner that did not answer changed the record") + } + l.statedErr, l.stated = nil, map[string]string{} + q.SayFacts(context.Background(), rep) + if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || !slices.Equal(got.Record.Capabilities, []string{"apt", "systemd"}) { + t.Errorf("a runner stating no calls: revision %d (was %d), capabilities %v", l.kv["n1.node"].rev, at, got.Record.Capabilities) + } + at++ + l.stated = map[string]string{CapabilitiesKey: "ledger-calls,bus-state-calls"} + q.SayFacts(context.Background(), rep) + at++ keys.Certificate = "cert-2" q.SayFacts(context.Background(), rep) if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || got.Record.Certificate != "cert-2" { diff --git a/internal/link/record.go b/internal/link/record.go index df204af..740184f 100644 --- a/internal/link/record.go +++ b/internal/link/record.go @@ -2,6 +2,7 @@ package link import ( "encoding/json" + "slices" "sort" "sync" @@ -26,6 +27,9 @@ type RecordKeys struct { Authority string // Trusts is what this machine trusts a mesh signature from now (delivery.Trusts, ADR 0550 §2). Trusts []string + // Stated is the capabilities its tool runner states of the calls it answers (CapabilitiesKey), which the record says + // beside the machine's own: a module whose manifest asks for one is held off a machine whose runner does not state it. + Stated []string } // manifestReads is what this engine reads of a manifest (novox/hq ADR 0614 §4): the reader capabilities of the mesh-sdk it @@ -60,6 +64,11 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { rec.Capabilities = append(rec.Capabilities, c.Name) } } + for _, c := range keys.Stated { + if c != "" && !slices.Contains(rec.Capabilities, c) { + rec.Capabilities = append(rec.Capabilities, c) + } + } sort.Strings(rec.Capabilities) if p.Machine != nil { rec.NetworksSaid = true diff --git a/internal/link/session.go b/internal/link/session.go index 3bcd467..7dd16af 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "git.novox.be/novox/mesh-sdk/go/delivery" + "strings" "sync" "time" @@ -79,6 +80,10 @@ type Session struct { haveAgainPending bool haveAgainTries int + // runnerStated is the capabilities this machine's tool runner last stated of the calls it answers (askRunner), said in the + // node's record. Held under saying. + runnerStated []string + // saying is held while a facts event is noted, so the haves are written in the order the facts are made. saying sync.Mutex @@ -161,6 +166,7 @@ func (q *Session) SayFacts(ctx context.Context, r Report) bool { return false } r.Node = q.Membership.Node + q.askRunner(ctx, bus) if q.noteApply(r) { q.writeHaves(ctx, bus) } @@ -404,3 +410,30 @@ func (q *Session) operatorAsks() (OperatorAsks, error) { } return asking, nil } + +// runnerAsk bounds the question to the tool runner. +const runnerAsk = 3 * time.Second + +// askRunner reads what this machine's tool runner states of the calls it answers, asked of its own instance with the PING +// the witness asks (ENG-2, issue 1966), at each report: a runner replaced by a build that states more, or less, is said in +// the record from the next report. A runner that does not answer states what it stated before, since its absence says +// nothing of its calls; one that answers without stating any states none, as an older runner does. Called under saying. +func (q *Session) askRunner(ctx context.Context, bus Bus) { + stater, ok := bus.(Stater) + if !ok { + return + } + asking, cancel := context.WithTimeout(ctx, runnerAsk) + defer cancel() + md, err := stater.Stated(asking, ToolRunnerService, q.Membership.Node) + if err != nil { + return + } + var stated []string + for _, c := range strings.Split(md[CapabilitiesKey], ",") { + if c = strings.TrimSpace(c); c != "" { + stated = append(stated, c) + } + } + q.runnerStated = stated +} diff --git a/internal/link/witnessing.go b/internal/link/witnessing.go index ed364d9..14cd1ec 100644 --- a/internal/link/witnessing.go +++ b/internal/link/witnessing.go @@ -47,27 +47,50 @@ func (q *Session) Asker() Asker { } func (l *natsLink) Ping(ctx context.Context, service, id string) error { + _, err := l.stated(ctx, service, id) + return err +} + +// ToolRunnerService is the services-protocol name the tool runner answers discovery under, and CapabilitiesKey the metadata +// key of its PING it states the capabilities of the calls it answers under, comma-separated (mesh-tools announce). +const ( + ToolRunnerService = "node-tools" + CapabilitiesKey = "capabilities" +) + +// Stater is a link that can ask what a service's instance states of itself in its PING: its metadata. +type Stater interface { + Stated(ctx context.Context, service, id string) (map[string]string, error) +} + +// Stated is what the tool runner of this machine states in its PING, asked of its own instance as a witness asks it. +func (l *natsLink) Stated(ctx context.Context, service, id string) (map[string]string, error) { + return l.stated(ctx, service, id) +} + +func (l *natsLink) stated(ctx context.Context, service, id string) (map[string]string, error) { subject := PingSubject(service, id) before := l.conn.LastError() msg, err := l.conn.RequestWithContext(ctx, subject, nil) switch { case errors.Is(err, nats.ErrNoResponders): // The bus delivered the question and nothing subscribes to it: the runtime is not on the bus. - return fmt.Errorf("%w: no %s on this machine is on the bus", ErrNoAnswer, service) + return nil, fmt.Errorf("%w: no %s on this machine is on the bus", ErrNoAnswer, service) case err != nil: if refused := l.refusal(subject, before); refused != "" { - return fmt.Errorf("%w: asking %s: %v%s", ErrCannotAsk, subject, err, refused) + return nil, fmt.Errorf("%w: asking %s: %v%s", ErrCannotAsk, subject, err, refused) } - return fmt.Errorf("%w: %s did not answer: %v", ErrNoAnswer, subject, err) + return nil, fmt.Errorf("%w: %s did not answer: %v", ErrNoAnswer, subject, err) } var ping struct { - Name string `json:"name"` - ID string `json:"id"` + Name string `json:"name"` + ID string `json:"id"` + Metadata map[string]string `json:"metadata"` } if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Name != service || ping.ID != id { - return fmt.Errorf("%w: what answered %s is not %s %s", ErrNoAnswer, subject, service, id) + return nil, fmt.Errorf("%w: what answered %s is not %s %s", ErrNoAnswer, subject, service, id) } - return nil + return ping.Metadata, nil } // refusal is the bus's word on this connection when it refused a publish to subject while this question -- 2.54.0 From 05418b4fe55d373161c9b223357aa313aca18554 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:43:17 +0100 Subject: [PATCH 02/73] ENG-7: every new image of a module of two containers or more is pulled before any of its containers is set aside, and a pull that fails replaces nothing (design 51, issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/apply/apply.go | 82 ++++++++++++++++++++++++++-- internal/apply/replace_image_test.go | 57 +++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 5fe2f2a..d97ff1d 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -939,6 +939,13 @@ func applyScoped( } } setRunnerWarnings(warnings) + // **Every new image of a module is on the machine before any of its containers is set aside** (design 51, issue 1966 + // ENG-7). A container replaced after the first one's turn pulled its image only then, and for a module whose first + // container serves the store its second image comes from, that pull met the stopped one. A pull that fails + // replaces nothing of the module: its containers keep running what they ran, the failure names the image, and what + // follows in the module is skipped as it is for any step that did not complete. + in.pulled = map[string]bool{} + pullFirst(ctx, ordered, d, in.pulled, run, &failures, gated, gatedWhy, log) for i, resource := range ordered { if !orphansRemoved && i == guardFirst { // **Only a guard that is up may let the filter go.** Removing the derived filter's @@ -2759,6 +2766,9 @@ type inputs struct { files map[string]bool // handed is every group a record that went this apply handed to a resource still asking for it. handed Handed + // pulled is every image this apply found on the machine, or pulled, before any container was replaced (pullFirst): + // applyContainer does not ask for it again. + pulled map[string]bool } // fileDigest is what a file the container reads holds, by digest. @@ -3165,8 +3175,10 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner, // gone: an image the registry does not hold then left the service down and every later apply // failing. Pulled (or found present) first, an unobtainable image fails this resource loudly, // naming the image, and the old container keeps running. - if err := ensureImage(ctx, cri, r.Image, run); err != nil { - return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err) + if !in.pulled[r.Image] { + if err := ensureImage(ctx, cri, r.Image, run); err != nil { + return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err) + } } // **The running container is set aside, never removed, until its replacement runs** (issue 1945): stopped and // renamed, so its name and ports are free, and started again under its own name when the replacement does not @@ -3469,16 +3481,16 @@ func ensureImage(ctx context.Context, cri, image string, run Runner) error { // already hold — built here, or handed over. There is no registry that answers for it, so // pulling would fail somewhere that names a network problem instead of a missing image. if strings.HasPrefix(image, "sha256:") { - return fmt.Errorf( + return &PullError{Image: image, Err: fmt.Errorf( "image %s is not on this machine, and an image named by its own digest cannot be "+ "fetched: nothing serves it. Build it here, or load it, before applying this", - image) + image)} } if _, err := run(ctx, cri, "pull", image); err != nil { - return fmt.Errorf("pulling image %s: %w", image, err) + return &PullError{Image: image, Err: fmt.Errorf("pulling image %s: %w", image, err)} } if _, err := run(ctx, cri, "image", "inspect", image); err != nil { - return fmt.Errorf("image %s is not present after pulling it: %w", image, err) + return &PullError{Image: image, Err: fmt.Errorf("image %s is not present after pulling it: %w", image, err)} } return nil } @@ -4340,3 +4352,61 @@ func ownerText(info os.FileInfo) string { } return fmt.Sprintf("%d:%d", uid, gid) } + +// pullFirst finds on the machine, or pulls, the image of each container the declaration runs before any is replaced +// (ENG-7) for a module of two containers or more. A container that is run once is left to its marker, and a runtime that +// does not answer to applyContainer, which says so. The first image that cannot be had fails its container with the image named and gates its module, so none of +// its containers is touched; the images found before it stay pulled. +func pullFirst(ctx context.Context, ordered []declaration.Resource, d *declaration.Declaration, pulled map[string]bool, + run Runner, failures *[]*Error, gated map[string]bool, gatedWhy map[string]string, log func(string)) { + // A module of one container already pulls its image before it sets that container aside (applyContainer), and nothing + // of it comes before: only a module of two or more has an image pulled after one of its containers was replaced. + count := map[string]int{} + for _, resource := range ordered { + if c, isContainer := resource.(*declaration.Container); isContainer && c.Image != "" && !c.RunOnce && c.Schedule == "" { + module, _ := d.ModuleOfID(c.Identity()) + count[module]++ + } + } + var cri string + for _, resource := range ordered { + c, isContainer := resource.(*declaration.Container) + if !isContainer || c.Image == "" || c.RunOnce || c.Schedule != "" || pulled[c.Image] { + continue + } + module, ours := d.ModuleOfID(c.Identity()) + if count[module] < 2 { + continue + } + if ours && gated[module] { + continue + } + if cri == "" { + var err error + if cri, err = containerRuntime(ctx, run); err != nil { + return + } + } + if err := ensureImage(ctx, cri, c.Image, run); err != nil { + err = fmt.Errorf("not replacing container %s, or any other container of its module, which keep running: %w", c.Name, err) + *failures = append(*failures, &Error{Resource: c.Identity(), Err: err}) + log(fmt.Sprintf(" failed %s (%s): %v", c.Identity(), c.Target(), err)) + if ours { + gated[module] = true + gatedWhy[module] = "its image " + c.Image + " could not be had, so none of its containers was replaced" + } + continue + } + pulled[c.Image] = true + } +} + +// PullError is an image that could not be found or pulled, as ensureImage fails: what the node agent reads to tell a failed +// pull from another failed apply (ENG-8). +type PullError struct { + Image string + Err error +} + +func (e *PullError) Error() string { return e.Err.Error() } +func (e *PullError) Unwrap() error { return e.Err } diff --git a/internal/apply/replace_image_test.go b/internal/apply/replace_image_test.go index 447366b..48ff6a9 100644 --- a/internal/apply/replace_image_test.go +++ b/internal/apply/replace_image_test.go @@ -47,6 +47,63 @@ func TestARunningContainerIsKeptWhenItsReplacementImageCannotBeObtained(t *testi if !strings.Contains(err.Error(), pinned) { t.Errorf("the failure does not name the image %s: %v", pinned, err) } + var pull *PullError + if !errors.As(err, &pull) || pull.Image != pinned { + t.Errorf("the failure is not a pull error naming %s: %v", pinned, err) + } + + // design 51, ENG-7: a module of two containers whose second new image cannot be pulled replaces neither, and says the + // image; with both pullable, both are pulled before the first is set aside. + second := strings.Replace(pinned, "postgres", "other", 1) + two := `{"declaration":1,"resources":[ + {"id":"a.first","type":"container","name":"first","image":"` + pinned + `","env":{"X":"1"}}, + {"id":"a.second","type":"container","name":"second","image":"` + second + `","env":{"X":"2"}} + ]}` + for _, c := range []struct { + name string + unpullable string + wantTouched bool + wantPulled string + }{ + {"the second image cannot be pulled", second, false, "pull " + pinned}, + {"both can", "", true, "pull " + pinned + "; pull " + second}, + } { + d := parseTrusted(t, two) + var steps []string + present := map[string]bool{} + run := func(ctx context.Context, name string, args ...string) (string, error) { + args = oldShape(name, args) + switch args[0] { + case "info": + return "27.0\n", nil + case "container": + return "true\tsome-older-spec", nil + case "image": + if present[args[len(args)-1]] { + return "", nil + } + return "", errors.New("no such image") + case "pull": + if args[1] == c.unpullable { + return "", errors.New("manifest unknown") + } + present[args[1]] = true + steps = append(steps, "pull "+args[1]) + case "rm", "rename", "stop", "run": + steps = append(steps, args[0]+" "+strings.Join(args[1:], " ")) + } + return "", nil + } + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) + got := strings.Join(steps, "; ") + touched := strings.Contains(got, "rename") || strings.Contains(got, "run ") + if touched != c.wantTouched || !strings.HasPrefix(got, c.wantPulled) { + t.Errorf("%s: steps %q (touched %v), want pulls %q first and touched=%v", c.name, got, touched, c.wantPulled, c.wantTouched) + } + if !c.wantTouched && (err == nil || !strings.Contains(err.Error(), second)) { + t.Errorf("%s: the failure does not name %s: %v", c.name, second, err) + } + } } func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing.T) { -- 2.54.0 From 60358d01e93d0ba4a6d77401228a1c90ef6d0346 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:43:46 +0100 Subject: [PATCH 03/73] ENG-note: notHandedWhy's impossible branch says it is a fault in the node-engine's composition (review of issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/compose/resolution/handed.go | 6 +++++- internal/compose/resolution/handed_bound_test.go | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/compose/resolution/handed.go b/internal/compose/resolution/handed.go index 5328a8b..8b7670a 100644 --- a/internal/compose/resolution/handed.go +++ b/internal/compose/resolution/handed.go @@ -161,7 +161,11 @@ func notHandedWhy(with mcat.Rendering, m mcat.Manifest, r map[string]any) (why s if why := machineWhy(with); why != "" { return why, true } - return fmt.Sprintf("its build kept a template for %s and this composition filled it itself", id), true + // Every way a handed file is not handed is named above: the module moved with templates, a template for this file, no + // merge, a machine that takes it. Reached, the composition and Handing disagree, and that is a fault in this code, said as + // one so it is reported and not read as a reason about the module or the machine (issue 1966). + return fmt.Sprintf("a fault in the node-engine's composition, to be reported: %s is moved, its build kept a template for %s, "+ + "and this machine takes handed files, yet the composition filled it itself", m.Module, id), true } // requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by diff --git a/internal/compose/resolution/handed_bound_test.go b/internal/compose/resolution/handed_bound_test.go index 52eabcd..a74af22 100644 --- a/internal/compose/resolution/handed_bound_test.go +++ b/internal/compose/resolution/handed_bound_test.go @@ -157,4 +157,12 @@ func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testi t.Fatalf("%s: the unmarked module is refused %v", name, before) } } + + // And where nothing keeps it from being handed yet the composition filled the file itself, the reason says it is a fault + // in this code (the review of the vault's judgement, issue 1966), never a reason about the module or the machine. + whole := mcat.Manifest{Module: "bot", Moved: true, Handed: &mcat.Handed{Templates: map[string]string{"f": "templates/f"}}} + if why, handedFile := notHandedWhy(handingTo("store:5000"), whole, map[string]any{"id": "f"}); !handedFile || + !strings.Contains(why, "a fault in the node-engine's composition") { + t.Errorf("the impossible branch says %q (handed file %v)", why, handedFile) + } } -- 2.54.0 From 3eef84d9796d40aeb182f505afb43c468aab94ad Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:46:33 +0100 Subject: [PATCH 04/73] ENG-10: the composition writes beside each routed image the seat it came through and that seat's holder, no part of a container's spec (design 51, issue 1966; needs mesh-sdk request R1) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/apply/replace_image_test.go | 10 ++++++++ internal/compose/rendering.go | 7 ++++++ internal/compose/rendering_test.go | 16 +++++++++++++ internal/compose/resolution/artifacts.go | 18 +++++++++++++++ internal/compose/resolution/artifacts_test.go | 23 +++++++++++++++++++ 5 files changed, 74 insertions(+) diff --git a/internal/apply/replace_image_test.go b/internal/apply/replace_image_test.go index 48ff6a9..99f6b5b 100644 --- a/internal/apply/replace_image_test.go +++ b/internal/apply/replace_image_test.go @@ -111,6 +111,16 @@ func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing {"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}} ]}`) want := containerSpec(d.Resources[0].(*declaration.Container), inputs{}) + // The seat and holder a routed image names (ENG-10) are no part of what a container is: another holder is the same spec, + // so a declaration composed again for it replaces no container. + for _, holder := range []string{"distribution", "elsewhere"} { + named := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"store","type":"container","name":"store","image":"`+pinned+`","image-seat":"mesh-artifact-store","image-holder":"`+holder+`","env":{"PGDATA":"/data"}} + ]}`) + if got := containerSpec(named.Resources[0].(*declaration.Container), inputs{}); got != want { + t.Errorf("a container naming the holder %s has another spec than one naming none", holder) + } + } var order []string created := false run := func(ctx context.Context, name string, args ...string) (string, error) { diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index 7aaae27..eb0e47d 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -168,6 +168,13 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). if store != "" { r.SeatReach = map[string]string{"mesh-artifact-store": store} + // And its holder, from the holdings on record, written beside each image routed through it (design 51, ENG-10). + for _, h := range c.in.Holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == "mesh-artifact-store" && h.Scope == catalogue.ScopeMesh && h.Module != "" { + r.SeatHolder = map[string]string{"mesh-artifact-store": h.Module} + break + } + } } r.Settings = settings r.Given = given diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go index 54b9c71..f8d1448 100644 --- a/internal/compose/rendering_test.go +++ b/internal/compose/rendering_test.go @@ -272,6 +272,22 @@ func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *test if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] { t.Errorf("built is %v", got) } + // Its holder, from the holdings on record, beside the address (design 51, ENG-10): none on record names none, and a change + // of the holder is the rendering's. + if got := placed(t, in, "anchor").Rendering.SeatHolder; got != nil { + t.Errorf("a store with no holder on record names %v", got) + } + in.Holdings = []catalogue.Held{{Claim: "mesh-artifact-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "artifacts"}} + if got := placed(t, in, "laptop").Rendering.SeatHolder["mesh-artifact-store"]; got != "" { + t.Errorf("a machine that reaches no store names its holder %q", got) + } + if got := placed(t, in, "anchor").Rendering.SeatHolder["mesh-artifact-store"]; got != "artifacts" { + t.Errorf("the store's holder is %q", got) + } + in.Holdings[0].Module = "distribution" + if got := placed(t, in, "anchor").Rendering.SeatHolder["mesh-artifact-store"]; got != "distribution" { + t.Errorf("after the holder changed it is %q", got) + } } // A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go index 559ac1b..ed10c5d 100644 --- a/internal/compose/resolution/artifacts.go +++ b/internal/compose/resolution/artifacts.go @@ -41,6 +41,7 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) module, resource["id"], written, mcat.ArtifactStoreProvision) } resource[key] = mcat.Routed(written, with.ArtifactStore) + imageSeatInto(resource, key, with) continue } if with.ArtifactStore == "" { @@ -59,7 +60,24 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) } if with.Built[repository] { resource[key] = mcat.Routed(recorded, with.ArtifactStore) + imageSeatInto(resource, key, with) } } return nil } + +// artifactSeat is the seat an image is routed through: the artifact store's (the registries' four seats follow as the +// references name them). +const artifactSeat = "mesh-artifact-store" + +// imageSeatInto writes beside a container's routed image the seat the reference came through and that seat's holder (design 51 +// *distribution's first delivery*, issue 1966 ENG-10), so the node-engine finds by the declaration whom a failed pull waits +// on, never by a module's name. Neither is part of what a container is: a declaration composed again for a new holder +// replaces no container. Nothing is written where the holder is not on record, or for a resource that is no container. +func imageSeatInto(resource map[string]any, key string, with mcat.Rendering) { + if key != "image" || resource["type"] != "container" || with.SeatHolder[artifactSeat] == "" { + return + } + resource["image-seat"] = artifactSeat + resource["image-holder"] = with.SeatHolder[artifactSeat] +} diff --git a/internal/compose/resolution/artifacts_test.go b/internal/compose/resolution/artifacts_test.go index e9ab076..9cdd471 100644 --- a/internal/compose/resolution/artifacts_test.go +++ b/internal/compose/resolution/artifacts_test.go @@ -46,6 +46,29 @@ func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) { t.Errorf("composing wrote the address into the catalogue's copy: %v", got) } + // The seat the image came through and its holder, beside it (ENG-10); a holder not on record writes neither, a public + // image and an archive carry none, and a holder changing changes the declaration but not the container's spec. + if got := fileNamed(out, "gitea.server"); got["image-seat"] != nil || got["image-holder"] != nil { + t.Errorf("a holder not on record was written: %v %v", got["image-seat"], got["image-holder"]) + } + held := mcat.Rendering{ArtifactStore: "anchor.internal:5101", SeatHolder: map[string]string{"mesh-artifact-store": "distribution"}} + held1, err := Declaration(r, held) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(held1, "gitea.server"); got["image-seat"] != "mesh-artifact-store" || got["image-holder"] != "distribution" { + t.Errorf("the routed image names its seat and holder as %v %v", got["image-seat"], got["image-holder"]) + } + for _, id := range []string{"gitea.config", "gitea.cache"} { + if got := fileNamed(held1, id); got["image-seat"] != nil || got["image-holder"] != nil || got["seat"] != nil { + t.Errorf("%s names a seat: %v", id, got) + } + } + held.SeatHolder = map[string]string{"mesh-artifact-store": "other-holder"} + if held2, err := Declaration(r, held); err != nil || fileNamed(held2, "gitea.server")["image-holder"] != "other-holder" { + t.Errorf("a new holder is not written: %v", err) + } + // And the store moves: the same record, another address, without a rebuild. out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"}) if err != nil { -- 2.54.0 From 2a71a8ff769c5dca4769da4b09969c4c135d2fd2 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:49:44 +0100 Subject: [PATCH 05/73] ENG-8, ENG-9, ENG-11: a failed pull from a store is tried again once per settled of the store's holder, heard on every node, and the failed have says whom it waits on and what triggered the last retry (design 51, issue 1966; needs mesh-sdk request R1, R2) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- cmd/mesh-host/backup_event_test.go | 61 ++++++++++++ cmd/mesh-host/main.go | 8 ++ cmd/mesh-host/nodeagent.go | 11 ++- cmd/mesh-host/pulls.go | 26 +++++ cmd/mesh-host/settled.go | 71 ++++++++++++++ internal/agent/agent.go | 150 +++++++++++++++++++++++++++-- internal/agent/agent_test.go | 122 ++++++++++++++++++++++- internal/apply/apply.go | 35 ++++++- 8 files changed, 467 insertions(+), 17 deletions(-) create mode 100644 cmd/mesh-host/pulls.go create mode 100644 cmd/mesh-host/settled.go diff --git a/cmd/mesh-host/backup_event_test.go b/cmd/mesh-host/backup_event_test.go index 51f1bf5..fa0d55f 100644 --- a/cmd/mesh-host/backup_event_test.go +++ b/cmd/mesh-host/backup_event_test.go @@ -6,10 +6,13 @@ import ( "errors" "fmt" "strings" + "sync" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/agent" + "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" ) @@ -85,4 +88,62 @@ func TestABackupEndsWhenItsEventSaysSo(t *testing.T) { t.Errorf("%s: asked %d times, %v", row.name, asked, err) } } + + // The events of the same kind (ENG-11): the status view's settled is told to the agent by the module that settled, on + // whichever node; a body with no module, or no JSON, tells nothing; the end of the link ends the hearing. + told := &settledRecorder{} + events := make(chan []byte, 4) + for _, body := range []string{`{"node":"n2","module":"distribution"}`, `{"node":"n1"}`, `not json`, `{"module":"gitea"}`} { + events <- []byte(body) + } + linked, end := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { defer close(done); listen(linked, events, func() settledTold { return told }, func(string) {}) }() + deadline := time.Now().Add(2 * time.Second) + for len(told.heard()) < 2 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + end() + <-done + if got := strings.Join(told.heard(), ","); got != "distribution,gitea" { + t.Errorf("the agent was told %q settled, want distribution,gitea", got) + } + // And the images an apply could not pull are said with the holder the declaration names beside each, none for a public + // one (ENG-8, ENG-10). + d, err := declaration.ParseFileTrusted([]byte(`{"declaration":1,"resources":[` + + `{"id":"a.first","type":"container","name":"first","image":"reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","image-seat":"mesh-artifact-store","image-holder":"distribution"},` + + `{"id":"a.second","type":"container","name":"second","image":"public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}`)) + if err != nil { + t.Fatal(err) + } + pull := func(image string) error { + return &apply.Error{Resource: "x", Err: &apply.PullError{Image: image, Err: errors.New("manifest unknown")}} + } + if got := pullsOf(d, pull("reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); len(got) != 1 || got[0].Seat != "mesh-artifact-store" || got[0].Holder != "distribution" { + t.Errorf("a store's image is said as %+v", got) + } + if got := pullsOf(d, pull("public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); len(got) != 1 || got[0].Holder != "" || got[0].Seat != "" { + t.Errorf("a public image is said as %+v", got) + } + if got := pullsOf(d, errors.New("another failure")); len(got) != 0 { + t.Errorf("a failure that is no pull is said as %+v", got) + } +} + +// settledRecorder is the agent a hearing tells. +type settledRecorder struct { + mu sync.Mutex + list []string +} + +func (r *settledRecorder) Settled(holder string) { + r.mu.Lock() + r.list = append(r.list, holder) + r.mu.Unlock() +} + +func (r *settledRecorder) heard() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.list...) } diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 1f056e0..0d03f6b 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1064,6 +1064,14 @@ func runLink(ctx context.Context, opts options) error { // And what it trusts, followed on the bus, said in its record whenever it changes (ADR 0550 §2). trust.onChange(func() { factsOfNow(aside, current.get()) }) go followTrust(aside, queue, trust, say) + // And the status view's `settled`, for a failed pull from a store to be tried again once the store's holder has settled + // (design 51, ENG-11). + go followSettled(aside, queue, func() settledTold { + if ag := current.get(); ag != nil { + return ag + } + return nil + }, say) // And the login it dials as, which the vault's node-logins names for this node (novox/hq ADR 0583 §1). rotator := newRotator(queue, opts.state, opts.timeout, say) go followLogins(aside, queue, mine.Node, func(nl delivery.NodeLogin) { diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 2ee61ae..f0e67b7 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -731,12 +731,19 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot if got.Refused != "" { return failedWith(got.Refused) } + // An image that could not be pulled from a store is said with its holder, for the engine to try the apply again once the + // holder has settled (design 51, ENG-8). + failedOn := func(why string) agent.Result { + res := failedWith(why) + res.Pulls = pullsOf(d, got.Err) + return res + } results := apply.ModuleResults(d, got.Outcome, got.Err) if r, ok := results[name]; ok && r.Failed { - return failedWith(r.Detail) + return failedOn(r.Detail) } if got.Err != nil { - return failedWith(got.Err.Error()) + return failedOn(got.Err.Error()) } if got.Probe != nil { return failedWith(got.Probe.partial().Error()) diff --git a/cmd/mesh-host/pulls.go b/cmd/mesh-host/pulls.go new file mode 100644 index 0000000..7703a5c --- /dev/null +++ b/cmd/mesh-host/pulls.go @@ -0,0 +1,26 @@ +package main + +import ( + "git.novox.be/novox/mesh-sdk/go/declaration" + + "github.com/novox/mesh-host/internal/agent" + "github.com/novox/mesh-host/internal/apply" +) + +// pullsOf is the images an apply could not find or pull, each with the seat it came through and that seat's holder as the +// declaration names them beside the container (design 51 *distribution's first delivery*, ENG-8, ENG-10). An image the +// declaration names no holder for (a public registry's) carries none: its pull is not tried again on a store settling. +func pullsOf(d *declaration.Declaration, err error) []agent.PullFailure { + var out []agent.PullFailure + for _, p := range apply.PullErrors(err) { + f := agent.PullFailure{Image: p.Image, Started: p.Started} + for _, r := range d.Resources { + if c, ok := r.(*declaration.Container); ok && c.Image == p.Image && c.ImageHolder != "" { + f.Seat, f.Holder = c.ImageSeat, c.ImageHolder + break + } + } + out = append(out, f) + } + return out +} diff --git a/cmd/mesh-host/settled.go b/cmd/mesh-host/settled.go new file mode 100644 index 0000000..5a7dd82 --- /dev/null +++ b/cmd/mesh-host/settled.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/novox/mesh-host/internal/link" +) + +// settledEvent is where the status view says a module has settled on a node: its have reached its want, running and healthy +// (design 47; the view's `settled`, said once for a module on a node at one composition). The engine hears it for every module +// and tells its agent, which tries again, once per settled, a failed apply that waits on that module as the holder of the store +// its image comes from (design 51 *distribution's first delivery*, issue 1966 ENG-11). The grants part grants the engine this +// subject (NodeAgentHears); a refused subscription is said, and made again when the next link opens. +const settledEvent = "mesh.mod.mesh-delivery-view.event.settled" + +// settledBody is what of the event the engine reads: the module that settled, on whichever node. +type settledBody struct { + Module string `json:"module"` +} + +// settledTold is who is told a holder has settled. +type settledTold interface{ Settled(holder string) } + +// followSettled hears the status view's `settled` on every link, never on a timer, until ctx ends. now names the agent running +// now (nil while the node agent begins again): a settled heard while there is none is not lost for it, since a new agent applies +// again every have that failed at its start. +func followSettled(ctx context.Context, session *link.Session, now func() settledTold, say link.Announce) { + said := "" + for ctx.Err() == nil { + linked, cancel, err := session.WhenLinked(ctx) + if err != nil { + return + } + events, stop, herr := session.Hear(settledEvent) + if herr != nil { + if line := fmt.Sprintf("a store settling is not heard yet (%s: %v): a failed pull waits for the next link, or for compose", settledEvent, herr); line != said { + said = line + say(line) + } + } else { + said = "" + listen(linked, events, now, say) + stop() + } + <-linked.Done() + cancel() + } +} + +// listen tells the agent each settled it hears, until the link ends. +func listen(linked context.Context, events <-chan []byte, now func() settledTold, say link.Announce) { + for { + select { + case <-linked.Done(): + return + case raw, ok := <-events: + if !ok { + return + } + var b settledBody + if json.Unmarshal(raw, &b) != nil || b.Module == "" { + continue + } + if told := now(); told != nil { + told.Settled(b.Module) + } + } + } +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9f63130..5a6fc1b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -82,6 +82,9 @@ type Haves interface { // Result is how one apply or removal came out. type Result struct { + // Pulls are the images the apply could not find or pull, each with the seat it came through and that seat's holder + // where the declaration names them (a store's image, ENG-10): what a failed apply waits on (ENG-8). + Pulls []PullFailure Failed bool // Transient says the failure was the bus not answering for a moment (no responders, a timeout, a reconnect, a // JetStream API error while the bus reloads), not the module's: the apply is tried again without a new revision. @@ -89,6 +92,14 @@ type Result struct { Detail string } +// PullFailure is one image an apply could not find or pull. Holder is the module holding the seat the image came through, +// "" for an image no store holds (a public registry's): its pull is tried again only by the changes that always start an +// apply. Started is when the pull began. +type PullFailure struct { + Image, Seat, Holder string + Started time.Time +} + // Backup is the answer of the backup asked before a module moves. // BackupInterrupted is the words of a backup the node agent itself stopped waiting for (its context ended): no failure, so // the next start backs the module up again rather than holding it for the operator. @@ -150,6 +161,9 @@ type Env struct { // client to fill again what it refused (issue 924, the second meeting: ace, g14 and shanks on 2026-10-29 at 00:09 // UTC refused power's three new drop-ins from an engine start on, as no module settled after it). Nil tells nothing. RunsChanged func(why string) + // Now is the clock the agent compares a pull's start with the time it heard a store's holder settled by; nil is + // time.Now. + Now func() time.Time // Backoff is how long to wait before the try-th retry (1, 2, …) of an apply that failed transiently. Nil retries a // transient failure only on RetryTransient or a change of either key. Backoff func(try int) time.Duration @@ -232,11 +246,17 @@ type Agent struct { failedAgain chan string // tries counts the transient failures in a row of each module; again carries a retry whose wait ran out, and // transient asks for every transiently failed module at once. stopped is closed when Run returns. - tries map[string]int - gen uint64 - again chan againAsk - transient chan struct{} - stopped chan struct{} + tries map[string]int + // pulls is, for each module whose last apply failed on a pull from a store, what it waits on (ENG-8); settledAt and + // settledSeq are, by the module holding a store, when its settled was last heard since this agent started and how many + // have been. Under mu. + pulls map[string]*pullWait + settledAt map[string]time.Time + settledSeq map[string]int + gen uint64 + again chan againAsk + transient chan struct{} + stopped chan struct{} } type againAsk struct { @@ -276,6 +296,7 @@ func New(env Env) *Agent { } return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{}, attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{}, + pulls: map[string]*pullWait{}, settledAt: map[string]time.Time{}, settledSeq: map[string]int{}, retry: make(chan []string, 8), reconcile: make(chan []string, 64), failedAgain: make(chan string, 8), tries: map[string]int{}, again: make(chan againAsk, 64), transient: make(chan struct{}, 1), stopped: make(chan struct{})} } @@ -1510,6 +1531,9 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseApplying)}) res := a.env.Apply(ctx, comp.C, others) a.mu.Lock() + if !res.Failed || res.Transient { + delete(a.pulls, module) + } what := fmt.Sprintf("%s at %s", module, fp) if module == NodeModule { what = "the node's own composition" @@ -1544,11 +1568,20 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo delete(a.tries, module) if res.Failed { a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev} + detail := res.Detail + again := a.notePullFailed(module, res.Pulls) + if words := a.pullWords(module); words != "" { + detail += words + } a.mu.Unlock() a.env.Say(fmt.Sprintf("applying %s failed: %s", what, res.Detail)) - a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: res.Detail}) + a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: detail}) + if again != "" { + a.askPullAgain(module, again) + } return } + delete(a.pulls, module) delete(a.attempt, module) delete(a.notComposed, module) // the apply says what it runs now a.running[module] = delivery.Have{FP: fp, Composed: dig, Phase: delivery.PhaseRunning} @@ -1584,6 +1617,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) { return } delete(a.attempt, module) + delete(a.pulls, module) delete(a.had, module) a.running[module] = delivery.Have{Phase: delivery.PhaseRemoved} a.mu.Unlock() @@ -1794,3 +1828,107 @@ func (a *Agent) readKeptData(ctx context.Context, modules ...string) { } } } + +// pullWait is what a module whose apply failed on a pull from a store waits on: the store's holder settling (ENG-8, +// design 51 *distribution's first delivery*). +type pullWait struct { + PullFailure + // retriedFor is the count of the holder's settled the last retry answered: a settled answers once. + retriedFor int + // last is what triggered the last retry, in words, and lastAt when; the have and the journal say it. + last string + lastAt time.Time +} + +func (a *Agent) now() time.Time { + if a.env.Now != nil { + return a.env.Now() + } + return time.Now() +} + +// notePullFailed records the pull a failed apply met from a store (the first image with a holder), keeping the settled it was +// last tried again for. It answers, when the holder's settled was heard after the pull started and not yet answered, the +// words of the retry to make at once (the race just before `settled`: ENG-11), else "". a.mu is held. +func (a *Agent) notePullFailed(module string, pulls []PullFailure) string { + var found *PullFailure + for i := range pulls { + if pulls[i].Holder != "" { + found = &pulls[i] + break + } + } + if found == nil { + delete(a.pulls, module) + return "" + } + pw := &pullWait{PullFailure: *found} + if before := a.pulls[module]; before != nil { + pw.retriedFor, pw.last, pw.lastAt = before.retriedFor, before.last, before.lastAt + } + a.pulls[module] = pw + holder := found.Holder + if seq := a.settledSeq[holder]; seq > pw.retriedFor && a.settledAt[holder].After(found.Started) { + return a.markPullRetry(pw, seq, fmt.Sprintf("%s settled at %s, after the pull of %s began at %s", holder, + a.settledAt[holder].Format("15:04:05"), found.Image, found.Started.Format("15:04:05"))) + } + return "" +} + +// markPullRetry notes that pw is tried again for the holder's settled number seq, and answers the words that say so. a.mu +// is held. +func (a *Agent) markPullRetry(pw *pullWait, seq int, why string) string { + pw.retriedFor, pw.last, pw.lastAt = seq, why, a.now() + return why +} + +// pullWords is what a failed have says of the wait: whose settling it is tried again on and what triggered the last retry. +// "" for a failure that waits on no holder. a.mu is held. +func (a *Agent) pullWords(module string) string { + pw := a.pulls[module] + if pw == nil { + return "" + } + words := fmt.Sprintf("; it is tried again once %s has settled", pw.Holder) + if pw.last != "" { + words += "; last tried again at " + pw.lastAt.Format("15:04:05") + ": " + pw.last + } + return words +} + +// askPullAgain asks the apply of a module again, because why, as a person's verb compose does (RetryFailed): said in the +// journal, never blocking. +func (a *Agent) askPullAgain(module, why string) { + select { + case a.failedAgain <- module: + a.env.Say(fmt.Sprintf("%s: its image could not be pulled from the store; applied again because %s", module, why)) + default: + a.env.Say(fmt.Sprintf("%s: its image could not be pulled from the store, and %s, but the engine has too many retries "+ + "asked; ask it with compose", module, why)) + } +} + +// Settled is the status view's word that a module has settled on some node (ENG-11): the engine's hearing tells it for every +// module it hears. When the module holds a store a failed apply of this node waits on, that apply is tried again once for +// this settled; a settled of any other module starts nothing. A pull that began after this settled, or has failed before it +// and is answered, is not tried again for it (notePullFailed). +func (a *Agent) Settled(holder string) { + a.mu.Lock() + a.settledSeq[holder]++ + a.settledAt[holder] = a.now() + seq := a.settledSeq[holder] + var asks []string + var modules []string + for module, pw := range a.pulls { + att, failed := a.attempt[module] + if pw.Holder != holder || !failed || att.Held || att.Transient || pw.retriedFor >= seq { + continue + } + modules = append(modules, module) + asks = append(asks, a.markPullRetry(pw, seq, fmt.Sprintf("%s settled", holder))) + } + a.mu.Unlock() + for i, module := range modules { + a.askPullAgain(module, asks[i]) + } +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index e2fed75..4592d48 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -8,6 +8,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "testing" "time" @@ -115,10 +116,14 @@ type bed struct { // transient marks failWith as the bus not answering; failTimes > 0 fails only the first that many applies. transient bool failTimes int - backup func(module string) Backup - kept map[string][]json.RawMessage - others [][]delivery.Composed - said []string + // pullsFn, when set, is the images a failed apply could not pull (called as the apply fails); during runs at the start of + // each apply, before it answers. + pullsFn func() []PullFailure + during func() + backup func(module string) Backup + kept map[string][]json.RawMessage + others [][]delivery.Composed + said []string // settled is each module Settled was told, and settledRan whether what runs held it then. settled []string settledRan []bool @@ -130,6 +135,9 @@ func newBed(t *testing.T) *bed { b := &bed{t: t, wants: newFeed(), comps: newFeed(), haves: &haves{}} b.env = Env{Node: node, Wants: b.wants, Composed: b.comps, Haves: b.haves, Apply: func(_ context.Context, c delivery.Composed, others []delivery.Composed) Result { + if b.during != nil { + b.during() + } b.mu.Lock() defer b.mu.Unlock() b.others = append(b.others, others) @@ -139,7 +147,11 @@ func newBed(t *testing.T) *bed { } b.applied = append(b.applied, name) if b.failWith != "" && (b.failTimes == 0 || len(b.applied) <= b.failTimes) { - return Result{Failed: true, Transient: b.transient, Detail: b.failWith} + res := Result{Failed: true, Transient: b.transient, Detail: b.failWith} + if b.pullsFn != nil { + res.Pulls = b.pullsFn() + } + return res } return Result{} }, @@ -461,6 +473,106 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { t.Errorf("a module running its want was applied again (%d applies)", n) } }) + // design 51 (ENG-8, ENG-11): a pull from a store that failed is tried again when the store's holder settles, once per + // settled, never on a clock; the have says whom it waits on and what triggered the last retry. + t.Run("a pull from a store waits for its holder to settle", func(t *testing.T) { + b := newBed(t) + var clock atomic.Int64 + now := func() time.Time { return time.Unix(1_000_000, clock.Load()) } + tick := func() time.Time { clock.Add(int64(time.Second)); return now() } + b.env.Now = now + b.env.Backoff = func(int) time.Duration { return time.Millisecond } + b.failWith = "pulling image reg/x@sha256:1: manifest unknown" + b.pullsFn = func() []PullFailure { + return []PullFailure{{Image: "reg/x@sha256:1", Seat: "mesh-artifact-store", Holder: "distribution", Started: tick()}, + {Image: "public/y@sha256:2"}} + } + stop := b.run() + defer stop() + fp := fpOf("a") + ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1")) + wk, wraw := wantFor(t, "web", fp, dig, "") + b.comps.put(ck, craw) + b.wants.put(wk, wraw) + eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 }) + eventually(t, "the have does not say whom it waits on", func() bool { + h := b.haves.last("web") + return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled") + }) + time.Sleep(100 * time.Millisecond) + if n := len(b.appliedList()); n != 1 { + t.Fatalf("a failed pull was tried again by the clock (%d applies)", n) + } + // A settled of another module starts nothing. + b.ag.Settled("gitea") + time.Sleep(50 * time.Millisecond) + if n := len(b.appliedList()); n != 1 { + t.Fatalf("a settled of another module applied the module (%d applies)", n) + } + // The holder's settled: tried again once, whose pull fails again after it (started later): failed again, no more. + tick() + b.ag.Settled("distribution") + eventually(t, "not tried again on the holder's settled", func() bool { return len(b.appliedList()) == 2 }) + eventually(t, "the have does not say what triggered the retry", func() bool { + h := b.haves.last("web") + return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled") + }) + time.Sleep(100 * time.Millisecond) + if n := len(b.appliedList()); n != 2 { + t.Fatalf("a second failure after the same settled started a further apply (%d applies)", n) + } + // The next settled answers once more, and the pull works: the module runs, and waits on nothing. + b.mu.Lock() + b.failWith = "" + b.mu.Unlock() + tick() + b.ag.Settled("distribution") + eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 }) + eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) + b.ag.Settled("distribution") + time.Sleep(50 * time.Millisecond) + if n := len(b.appliedList()); n != 3 { + t.Fatalf("a running module was applied again by a settled (%d applies)", n) + } + }) + // The race just before `settled`: the holder's settled is heard while the pull is in flight, and the pull fails after it; + // applied again at once, once, and a settled heard before the pull began applies nothing at once. + t.Run("a settled heard while the pull ran is not missed", func(t *testing.T) { + b := newBed(t) + var clock atomic.Int64 + now := func() time.Time { return time.Unix(1_000_000, clock.Load()) } + tick := func() time.Time { clock.Add(int64(time.Second)); return now() } + b.env.Now = now + b.failWith = "pulling image reg/x@sha256:1: connection refused" + began := tick() + first := true + b.during = func() { + if first { + first = false + tick() + b.ag.Settled("distribution") // the holder settles while this apply's pull is in flight + } + } + b.pullsFn = func() []PullFailure { + started := began + if len(b.applied) > 1 { + started = tick() // the retry's pull begins after the settled + } + return []PullFailure{{Image: "reg/x@sha256:1", Seat: "mesh-artifact-store", Holder: "distribution", Started: started}} + } + stop := b.run() + defer stop() + fp := fpOf("a") + ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1")) + wk, wraw := wantFor(t, "web", fp, dig, "") + b.comps.put(ck, craw) + b.wants.put(wk, wraw) + eventually(t, "the settled heard during the pull was missed", func() bool { return len(b.appliedList()) == 2 }) + time.Sleep(100 * time.Millisecond) + if n := len(b.appliedList()); n != 2 { + t.Fatalf("the race retry failing again started a further apply (%d applies)", n) + } + }) // The bus did not answer for a moment (mesh-vault on the bus node, 2026-10-25 23:04:24): tried again with no new // revision of either key, by the backoff or by RetryTransient once the engine knows the bus is back; the have says // retrying meanwhile, never failed. diff --git a/internal/apply/apply.go b/internal/apply/apply.go index d97ff1d..260e58a 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -3481,16 +3481,17 @@ func ensureImage(ctx context.Context, cri, image string, run Runner) error { // already hold — built here, or handed over. There is no registry that answers for it, so // pulling would fail somewhere that names a network problem instead of a missing image. if strings.HasPrefix(image, "sha256:") { - return &PullError{Image: image, Err: fmt.Errorf( + return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf( "image %s is not on this machine, and an image named by its own digest cannot be "+ "fetched: nothing serves it. Build it here, or load it, before applying this", image)} } + started := time.Now() if _, err := run(ctx, cri, "pull", image); err != nil { - return &PullError{Image: image, Err: fmt.Errorf("pulling image %s: %w", image, err)} + return &PullError{Image: image, Started: started, Err: fmt.Errorf("pulling image %s: %w", image, err)} } if _, err := run(ctx, cri, "image", "inspect", image); err != nil { - return &PullError{Image: image, Err: fmt.Errorf("image %s is not present after pulling it: %w", image, err)} + return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf("image %s is not present after pulling it: %w", image, err)} } return nil } @@ -4405,7 +4406,33 @@ func pullFirst(ctx context.Context, ordered []declaration.Resource, d *declarati // pull from another failed apply (ENG-8). type PullError struct { Image string - Err error + // Started is when the pull began, which the node agent compares with when a store's holder settled (ENG-11). + Started time.Time + Err error +} + +// PullErrors are the pull errors of every failure an apply reports, the first among them and the rest in Failures, in the +// order they happened: what the node agent tells a failed pull from another failed apply by (ENG-8). Nil for an error +// that is no apply's, or whose failures met none. +func PullErrors(err error) []*PullError { + var out []*PullError + var e *Error + if errors.As(err, &e) { + for _, f := range e.Failures { + var p *PullError + if errors.As(f.Err, &p) { + out = append(out, p) + } + } + if len(out) > 0 { + return out + } + } + var p *PullError + if errors.As(err, &p) { + out = append(out, p) + } + return out } func (e *PullError) Error() string { return e.Err.Error() } -- 2.54.0 From 18c1735f7c624365cb3203d4cb5a661f0e9381f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:07:34 +0100 Subject: [PATCH 06/73] ENG-5: the bootstrap makes a one-off push credential for the holders' first images, writes it for the store's holder and forgets it when its run ends (design 51, issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- cmd/mesh-bootstrap/main.go | 4 +- internal/bootstrap/bootstrap.go | 27 +++++++++++-- internal/bootstrap/push_credential.go | 55 +++++++++++++++++++++++++++ internal/bootstrap/trust_test.go | 17 +++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 internal/bootstrap/push_credential.go diff --git a/cmd/mesh-bootstrap/main.go b/cmd/mesh-bootstrap/main.go index 369a744..46ccad0 100644 --- a/cmd/mesh-bootstrap/main.go +++ b/cmd/mesh-bootstrap/main.go @@ -188,7 +188,9 @@ func parseArgs(args []string) (string, bootstrap.Options, bool, error) { Out: defaultOut, State: store.DefaultPath, Registry: defaultRegistry, - Host: defaultHost, + // Where the store's holder reads the one-off credential the holders' first images are pushed with (design 51, ENG-5). + PushCredentialFile: "/var/lib/mesh/registry/bootstrap-push", + Host: defaultHost, // The machine's own name, because that is what a person already calls it and an installer // inventing a different one would leave the mesh naming a machine nobody recognises. It is // read here rather than inside the bootstrap so that --node overrides a fact rather than a diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 425c9a4..2d4b2ff 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -157,6 +157,12 @@ type Options struct { // an address the others can reach. Registry string + // PushCredentialFile is where the bootstrap writes its one-off push credential for the store's holder to find, the + // holder's state file `bootstrap-push` on the node that holds it (design 51 *The move*, issue 1966 ENG-5); empty writes + // none. PushCredentialOwner is the user id and group id the file is given when the holder's account is known. + PushCredentialFile string + PushCredentialOwner *[2]int + // Host is the `mesh-host` binary on this machine — the program that enrols and then holds the // machine to what the mesh says. The installer runs it; it does not contain it. Host string @@ -296,10 +302,12 @@ type Result struct { Agent string `json:"host-agent,omitempty"` // Registry is the mesh's own artifact store, once it answers. - Registry string `json:"registry,omitempty"` - RegistryReplied int `json:"registry-replied,omitempty"` - RegistryKnown bool `json:"registry-already-registered,omitempty"` - RegistryRunning string `json:"registry-container,omitempty"` + Registry string `json:"registry,omitempty"` + // PushCredentialFile is where this run left its one-off push credential (never the credential itself). + PushCredentialFile string `json:"push-credential-file,omitempty"` + RegistryReplied int `json:"registry-replied,omitempty"` + RegistryKnown bool `json:"registry-already-registered,omitempty"` + RegistryRunning string `json:"registry-container,omitempty"` // RegistryTrusted are the names the runtime was told to trust the registry by, before the push. RegistryTrusted []string `json:"registry-trusted,omitempty"` PublishedAs string `json:"control-plane-image,omitempty"` @@ -815,6 +823,17 @@ func RegistryAndPublish(ctx context.Context, o Options, d Deps, sys system.Syste if err != nil { return Published{}, failed(StepRegistry, err) } + // The one-off credential the holders' first images are pushed to the store's front with, written for its holder to find + // and forgotten when this run ends; the holder discards the file once it answers its first publish (ENG-5). + if o.PushCredentialFile != "" { + cred, err := MintPushCredential(o.PushCredentialFile, o.PushCredentialOwner) + if err != nil { + return Published{}, failed(StepRegistry, err) + } + defer cred.Forget() + result.PushCredentialFile = cred.File + say(" a one-off credential for the holders' first images is at " + cred.File + "; the store's holder discards it once it serves publish") + } // ---- 8. publish ----------------------------------------------------------------------- say("publish — the control plane's image gets its first manifest digest") diff --git a/internal/bootstrap/push_credential.go b/internal/bootstrap/push_credential.go new file mode 100644 index 0000000..5331609 --- /dev/null +++ b/internal/bootstrap/push_credential.go @@ -0,0 +1,55 @@ +package bootstrap + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" +) + +// PushCredential is the bootstrap's one-off credential for the holders' first images (design 51 *The move*; issue 1966 ENG-5): +// a bearer token of 32 random bytes, hex, on one line, made for one fresh-mesh run. No vault and no bus holds it: this run +// holds it and writes it once, to the file the store's holder reads on each write (`Authorization: Bearer ` on an OCI +// write to the store's port, from any node); the holder removes the file when it answers its first `publish`, and this run +// forgets the token when it ends. On the holder's own node the bootstrap may push to the registry container's loopback with +// no credential at all. +type PushCredential struct { + // File is where the token was written. + File string + token string +} + +// Token is the bearer token, for a push through the store's front from another node. +func (c *PushCredential) Token() string { return c.token } + +// Forget drops the token from this run's memory. The file is the holder's to remove. +func (c *PushCredential) Forget() { c.token = "" } + +// MintPushCredential makes a token and writes it to file, mode 0600, owned by owner (user id, group id) when given; the +// directory is made if it is not there. A file that is there is replaced: a credential left by an earlier run is never reused. +func MintPushCredential(file string, owner *[2]int) (*PushCredential, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return nil, fmt.Errorf("a push credential cannot be made: %w", err) + } + token := hex.EncodeToString(raw) + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + return nil, fmt.Errorf("the push credential's directory %s cannot be made: %w", filepath.Dir(file), err) + } + tmp := file + ".new" + if err := os.WriteFile(tmp, []byte(token+"\n"), 0o600); err != nil { + return nil, fmt.Errorf("the push credential cannot be written to %s: %w", file, err) + } + if owner != nil { + if err := os.Chown(tmp, owner[0], owner[1]); err != nil { + os.Remove(tmp) + return nil, fmt.Errorf("the push credential cannot be given to the store's holder: %w", err) + } + } + if err := os.Rename(tmp, file); err != nil { + os.Remove(tmp) + return nil, fmt.Errorf("the push credential cannot be put at %s: %w", file, err) + } + return &PushCredential{File: file, token: token}, nil +} diff --git a/internal/bootstrap/trust_test.go b/internal/bootstrap/trust_test.go index 5e406b8..4ca6007 100644 --- a/internal/bootstrap/trust_test.go +++ b/internal/bootstrap/trust_test.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "testing" "time" @@ -263,6 +264,9 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { return http.StatusNotFound, "", nil }} var result Result + // And the one-off credential for the holders' first images (ENG-5): written once, mode 0600, one line of 64 hex digits, + // named in the result and never put in it. + o.PushCredentialFile = filepath.Join(t.TempDir(), "registry", "bootstrap-push") published, err := RegistryAndPublish(context.Background(), o, d, sys, controlPlane{container: "temp-mesh-controller", run: run, timeout: time.Second}, held, &result, func(string) {}) if err != nil { @@ -287,6 +291,19 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { t.Errorf("the trust was not written (%d) and confirmed (%d) before the first tag or push (%d): %v", reloaded, confirmed, firstPush, runtime.commands) } + if raw, err := os.ReadFile(o.PushCredentialFile); err != nil || !regexp.MustCompile(`^[0-9a-f]{64}\n$`).Match(raw) { + t.Errorf("the push credential is %q (%v)", raw, err) + } else if st, _ := os.Stat(o.PushCredentialFile); st.Mode().Perm() != 0o600 { + t.Errorf("the push credential is mode %v", st.Mode().Perm()) + } + if result.PushCredentialFile != o.PushCredentialFile { + t.Errorf("the result names %q", result.PushCredentialFile) + } + if again, err := MintPushCredential(o.PushCredentialFile, nil); err != nil || again.Token() == "" { + t.Errorf("a credential of an earlier run was not replaced: %v", err) + } else if again.Forget(); again.Token() != "" { + t.Errorf("a forgotten credential still holds its token") + } if fmt.Sprint(result.RegistryTrusted) != "[192.0.2.10:5100 anchor.internal:5100]" { t.Errorf("the result says it trusted %v", result.RegistryTrusted) } -- 2.54.0 From 7f33c8b499515269a3c3c3bc5f826146a1920ef3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:11:34 +0100 Subject: [PATCH 07/73] =?UTF-8?q?ENG-3,=20ENG-4:=20the=20node-engine=20kee?= =?UTF-8?q?ps=20no=20seat=20table=20of=20its=20own=20(mesh-contracts'=20li?= =?UTF-8?q?st=20through=20mesh-sdk),=20answers=20${seat:...:reach}=20for?= =?UTF-8?q?=20both=20names=20of=20the=20store,=20and=20reads=20both=20refe?= =?UTF-8?q?rence=20forms=20in=20composition,=20image=20seat=20and=20bootst?= =?UTF-8?q?rap=20(ADR=200605=20=C2=A79,=20design=2026,=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/bootstrap/bus_adoption.go | 5 +- internal/bootstrap/bus_adoption_test.go | 8 + internal/compose/rendering.go | 19 +- internal/compose/resolution/artifacts.go | 20 +- internal/compose/resolution/artifacts_test.go | 16 + .../resolution/control_records_seats.go | 52 -- internal/compose/resolution/engine_seat.go | 11 - internal/compose/resolution/files.go | 68 -- .../compose/resolution/graphical_session.go | 228 ------- internal/compose/resolution/health_seats.go | 104 --- internal/compose/resolution/holdings_test.go | 17 +- internal/compose/resolution/reads.go | 58 -- internal/compose/resolution/seat_into.go | 5 +- internal/compose/resolution/seat_into_test.go | 11 + internal/compose/resolution/seats.go | 598 +----------------- internal/compose/seats_test.go | 15 +- 16 files changed, 102 insertions(+), 1133 deletions(-) delete mode 100644 internal/compose/resolution/control_records_seats.go delete mode 100644 internal/compose/resolution/engine_seat.go delete mode 100644 internal/compose/resolution/graphical_session.go delete mode 100644 internal/compose/resolution/health_seats.go diff --git a/internal/bootstrap/bus_adoption.go b/internal/bootstrap/bus_adoption.go index 2e7ca7a..ee8d8dc 100644 --- a/internal/bootstrap/bus_adoption.go +++ b/internal/bootstrap/bus_adoption.go @@ -69,8 +69,9 @@ func adoptTheBus(ctx context.Context, o Options, control controlPlane, bus *decl return nil } -// meshBuiltImage is an image the mesh built, as it records it: `artifact-store:///@sha256:…`. -var meshBuiltImage = regexp.MustCompile(`artifact-store://([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`) +// meshBuiltImage is an image the mesh built, as it records it in either form it reads (ADR 0605 §9, issue 1966 ENG-4): +// `artifact-store:///@sha256:…`, or the registry seat's `container-image-registry//@sha256:…`. +var meshBuiltImage = regexp.MustCompile(`(?:artifact-store://|container-image-registry/)([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`) // theModulesBusImage is the nats module's server image, as the build just made it, pulled from this // mesh's registry: the image that carries the snapshot program. diff --git a/internal/bootstrap/bus_adoption_test.go b/internal/bootstrap/bus_adoption_test.go index 1c16bf1..71a12c1 100644 --- a/internal/bootstrap/bus_adoption_test.go +++ b/internal/bootstrap/bus_adoption_test.go @@ -139,4 +139,12 @@ func TestTheBundlesBusRunsTheReleaseTheModulesImageIsBuiltOn(t *testing.T) { if got, want := containerNamed(rewritten.Declaration, "mesh-broker-nats").Image, repository+"@"+digest; got != want { t.Errorf("the bundle raises the bus on %s; the nats module's server is %s (%s)", got, want, from) } + + // The image the mesh built is read in either form it is recorded in (ENG-4). + sum := strings.Repeat("c", 64) + for _, said := range []string{"artifact-store://nats/server@sha256:" + sum, "container-image-registry/nats/server@sha256:" + sum} { + if m := meshBuiltImage.FindStringSubmatch("build 1: " + said); m == nil || m[1] != "nats/server@sha256:"+sum { + t.Errorf("%s is read as %v", said, m) + } + } } diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index eb0e47d..770db5e 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -167,14 +167,21 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). if store != "" { - r.SeatReach = map[string]string{"mesh-artifact-store": store} - // And its holder, from the holdings on record, written beside each image routed through it (design 51, ENG-10). - for _, h := range c.in.Holdings { - if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == "mesh-artifact-store" && h.Scope == catalogue.ScopeMesh && h.Module != "" { - r.SeatHolder = map[string]string{"mesh-artifact-store": h.Module} - break + // Both names the store is reached through answer the one address (ENG-3), each with its holder from the holdings on + // record, written beside every image routed through it (design 51, ENG-10). + r.SeatReach, r.SeatHolder = map[string]string{}, map[string]string{} + for _, seat := range []string{"mesh-artifact-store", "container-image-registry"} { + r.SeatReach[seat] = store + for _, h := range c.in.Holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat && h.Scope == catalogue.ScopeMesh && h.Module != "" { + r.SeatHolder[seat] = h.Module + break + } } } + if len(r.SeatHolder) == 0 { + r.SeatHolder = nil + } } r.Settings = settings r.Given = given diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go index ed10c5d..be3ce85 100644 --- a/internal/compose/resolution/artifacts.go +++ b/internal/compose/resolution/artifacts.go @@ -41,7 +41,7 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) module, resource["id"], written, mcat.ArtifactStoreProvision) } resource[key] = mcat.Routed(written, with.ArtifactStore) - imageSeatInto(resource, key, with) + imageSeatInto(resource, key, written, with) continue } if with.ArtifactStore == "" { @@ -60,7 +60,7 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) } if with.Built[repository] { resource[key] = mcat.Routed(recorded, with.ArtifactStore) - imageSeatInto(resource, key, with) + imageSeatInto(resource, key, written, with) } } return nil @@ -74,10 +74,18 @@ const artifactSeat = "mesh-artifact-store" // *distribution's first delivery*, issue 1966 ENG-10), so the node-engine finds by the declaration whom a failed pull waits // on, never by a module's name. Neither is part of what a container is: a declaration composed again for a new holder // replaces no container. Nothing is written where the holder is not on record, or for a resource that is no container. -func imageSeatInto(resource map[string]any, key string, with mcat.Rendering) { - if key != "image" || resource["type"] != "container" || with.SeatHolder[artifactSeat] == "" { +func imageSeatInto(resource map[string]any, key, written string, with mcat.Rendering) { + if key != "image" || resource["type"] != "container" { return } - resource["image-seat"] = artifactSeat - resource["image-holder"] = with.SeatHolder[artifactSeat] + // The seat a reference of the registry form names; the older forms came through the artifact store's. + seat := artifactSeat + if named, _, ok := mcat.InRegistrySeat(written); ok { + seat = named + } + if with.SeatHolder[seat] == "" { + return + } + resource["image-seat"] = seat + resource["image-holder"] = with.SeatHolder[seat] } diff --git a/internal/compose/resolution/artifacts_test.go b/internal/compose/resolution/artifacts_test.go index 9cdd471..a43e083 100644 --- a/internal/compose/resolution/artifacts_test.go +++ b/internal/compose/resolution/artifacts_test.go @@ -64,6 +64,22 @@ func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) { t.Errorf("%s names a seat: %v", id, got) } } + // A reference in the form a registry seat answers is routed the same way, names the seat it came through (ENG-4), and its + // bundle is fetched as an archive from the store. + seatForm := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", "image": "container-image-registry/gitea/server@" + digest}, + {"id": "config", "type": "archive", "path": "/etc/gitea", "source": "oci-artifact-registry/gitea/config@" + digest}}}}} + held.SeatHolder["container-image-registry"] = "distribution" + seated, err := Declaration(seatForm, held) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(seated, "gitea.server"); got["image"] != "anchor.internal:5101/gitea/server@"+digest || got["image-seat"] != "container-image-registry" || got["image-holder"] != "distribution" { + t.Errorf("a seat-form image is %v, naming %v %v", got["image"], got["image-seat"], got["image-holder"]) + } + if got := fileNamed(seated, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest { + t.Errorf("a seat-form archive is fetched from %v", got) + } held.SeatHolder = map[string]string{"mesh-artifact-store": "other-holder"} if held2, err := Declaration(r, held); err != nil || fileNamed(held2, "gitea.server")["image-holder"] != "other-holder" { t.Errorf("a new holder is not written: %v", err) diff --git a/internal/compose/resolution/control_records_seats.go b/internal/compose/resolution/control_records_seats.go deleted file mode 100644 index d3b53f1..0000000 --- a/internal/compose/resolution/control_records_seats.go +++ /dev/null @@ -1,52 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// controlRecordsDecision is the record that made the two seats. -const controlRecordsDecision = "novox/hq ADR 0565" - -// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15). -func wantsVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " + - "and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " + - "rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " + - "with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", - Input: schema(map[string]string{ - "module": "one module", - "policy": "with module: roll, together, record, or default to take a person's choice back", - "why": "with policy: why, required and kept", - "compare": "\"true\": each node's own composition, by its digest, and the assigner's findings", - }, nil, "compare")}, - {Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " + - "indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " + - "the step itself is the put on the operator's warrant.", - Input: schema(map[string]string{}, nil)}, - } -} - -// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them. -func handActsVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " + - "(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " + - "runtime proves, never said by the caller. Said as act-recorded.", - Input: schema(map[string]string{ - "what": "what was done, in a line", - "why": "why it had to be done by hand", - "cause": "the cause in a word, or a condition's kind", - "condition": "the key of the condition it addressed, if any (optional)", - "verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", - }, []string{"what", "why"})}, - {Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " + - "cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", - Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause", - "who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")}, - {Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " + - "with which proofs and which answer read from the router's own record of that module's ask, never from the " + - "caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", - Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})}, - } -} diff --git a/internal/compose/resolution/engine_seat.go b/internal/compose/resolution/engine_seat.go deleted file mode 100644 index 58408be..0000000 --- a/internal/compose/resolution/engine_seat.go +++ /dev/null @@ -1,11 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// engineSeat is the seat's row in the closed set. -func engineSeat() mcat.Seat { - return mcat.Seat{Name: mcat.EngineSeat, Scope: mcat.ScopeNode, Serves: mcat.EngineVerbs(), Emits: append([]string(nil), mcat.EngineEvents...), - HeldByTheEngine: true, Decision: "novox/hq ADR 0563"} -} diff --git a/internal/compose/resolution/files.go b/internal/compose/resolution/files.go index 3b20ba9..0b7d706 100644 --- a/internal/compose/resolution/files.go +++ b/internal/compose/resolution/files.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "path" - "strings" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -14,73 +13,6 @@ const filesAsAccount = "as the node's operator account, through sudo -n where it const overrideWord = "a reason, to change a path a module owns anyway (optional)" -// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads, -// with their descriptions restated for the operator's account, and the writes. -func filesVerbs() []mcat.Verb { - var verbs []mcat.Verb - for _, v := range readsVerbs() { - v.Description = strings.Replace(v.Description, - "Read as the holder's own account, which holds no secret: a component it may not reach says so.", - "", 1) - v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "." - verbs = append(verbs, v) - } - write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) mcat.Verb { - in["reason"] = "why the file is changed, for the audit trail" - in["override"] = overrideWord - return mcat.Verb{Name: name, Description: description + " Refuses a path a module owns " + - "unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces} - } - return append(verbs, - write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.", - map[string]string{"path": "an absolute path", "content": "the text to write", - "mode": "the file's mode, such as 0644 (optional)"}, - []string{"path", "content"}, []string{"mode"}, "tee", ">"), - write("copy", "Copy a file or directory to a path on this node. A target on another node (:/path) is "+ - "refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+ - "kept in the node's trash for 7 days.", - map[string]string{"from": "an absolute path", "to": "an absolute path on this node"}, - []string{"from", "to"}, nil, "cp", "cp -a", "ssh cp"), - write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+ - "comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.", - map[string]string{"from": "an absolute path", "to": "an absolute path"}, - []string{"from", "to"}, nil, "mv"), - write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.", - map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"}, - []string{"path"}, []string{"recursive"}, "rm", "rm -r"), - write("mkdir", "Make a directory.", - map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"}, - []string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"), - write("chmod", "Change the mode of a file or directory.", - map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x", - "recursive": "\"true\": also everything under a directory"}, - []string{"path", "mode"}, []string{"recursive"}, "chmod"), - write("chown", "Change the owner and group of a file or directory.", - map[string]string{"path": "an absolute path", "owner": "an account's name or number", - "group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"}, - []string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), mcat.Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " + - "and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " + - "At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " + - "before anything is sent. Read " + filesAsAccount + ".", - Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into", - "key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})}, mcat. - // download is a plain Verb, not through write(): write() types its optional arguments as true/false - // switches, which a hash and a reason are not (issue 1719). - Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " + - "never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " + - "the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " + - "unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path", - "sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)", - "reason": "why the file is changed, for the audit trail", - "override": overrideWord}, []string{"bucket", "key", "path", "reason"})}, mcat.Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " + - "original path and when it went.", - Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.", - map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"), - ) -} - // ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the // paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each // module's manifest places (declaredPaths). A seat's path wins where both name one. diff --git a/internal/compose/resolution/graphical_session.go b/internal/compose/resolution/graphical_session.go deleted file mode 100644 index 059d9fd..0000000 --- a/internal/compose/resolution/graphical_session.go +++ /dev/null @@ -1,228 +0,0 @@ -package resolution - -import ( - "fmt" - "strings" - - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs -// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead, -// ADR 0255), the compositor and the secret -// service are roles a second holder competes for, and nothing has needed to ask them anything. -func graphicalSessionSeats() []mcat.Seat { - const decided = "novox/hq ADR 0208" - return []mcat.Seat{ - {Name: mcat.LoginManagerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{ - {Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " + - "one the operator account starts by default.", - Input: schema(map[string]string{}, nil)}, - }}, - {Name: mcat.DisplayServerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{ - {Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " + - "where it is placed; and the layout profile in force, if one matches.", - Input: schema(map[string]string{}, nil)}, - // Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6). - {Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " + - "identities: list them, save the current arrangement under a name, or apply one.", - Input: withEnum(schema(map[string]string{ - "action": "list, save or apply", - "name": "the profile to save or apply (save and apply only)", - }, []string{"action"}), "action", "list", "save", "apply")}, - }}, - // It receives window-manager configuration lines from every other module (novox/hq ADR 0212): - // bindings, start-up commands, rules — placed by the session's holder, never written into - // its directory by the contributor. - {Name: mcat.DisplaySessionSeat, Scope: mcat.ScopeNode, Decision: decided, - Receives: []mcat.Receivable{{Kind: "config", Comment: "#"}}, Serves: []mcat.Verb{ - {Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.", - Input: schema(map[string]string{}, nil)}, - {Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " + - "it is visible or focused.", - Input: schema(map[string]string{}, nil)}, - {Name: "windows", Description: "The session's windows: each one's title, class, workspace and " + - "whether it has focus; narrowed to one workspace when named.", - Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)}, - }}, - {Name: mcat.TerminalEmulatorSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{ - {Name: "open", Description: "Open a terminal window in the operator's session, running a command " + - "or the login shell, in a directory or the account's home.", - Input: schema(map[string]string{ - "command": "what to run in it (optional; the login shell when absent)", - "directory": "where it starts (optional; the account's home when absent)", - }, nil)}, - }}, - {Name: mcat.LauncherSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{ - {Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " + - "one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.", - Input: map[string]any{"type": "object", "required": []string{"choices"}, - "properties": map[string]any{ - "choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "the lines to choose between, in order"}, - "prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"}, - }}}, - // A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer - // is sealed to the asker's key, so it is never plaintext on the bus or in any call's record. - // **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live. - {Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " + - "does not show what is typed, and answer it sealed to the key the asker gives — never in " + - "the clear — or cancelled when the prompt was dismissed or not answered in time.", - // By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the - // module, the secret and the machine, and says the controller asks — the bus lets nobody else - // ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator. - Input: schema(map[string]string{ - "module": "the module whose own secret is asked for", - "secret": "the own secret's name", - "node": "the machine the module runs on", - "seal_to": "the asker's public sealing key: the answer is sealed to it", - "timeout_seconds": "give up after this long (optional)", - }, []string{"module", "secret", "node", "seal_to"})}, - }}, - {Name: mcat.NotifierSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{ - {Name: "send", Description: "Show the operator a notification.", - Input: withEnum(schema(map[string]string{ - "title": "the notification's summary", - "body": "its text (optional)", - "urgency": "low, normal (the default) or critical", - }, []string{"title"}), "urgency", "low", "normal", "critical")}, - {Name: "history", Description: "The notifications shown lately, newest first.", - Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)}, - }}, - {Name: mcat.LockScreenSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{ - {Name: "lock", Description: "Lock the operator's session now.", - Input: schema(map[string]string{}, nil)}, - }}, - {Name: mcat.ClipboardSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{ - {Name: "history", Description: "What the clipboard held lately, newest first.", - Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)}, - {Name: "copy", Description: "Put text on the operator's clipboard.", - Input: schema(map[string]string{"text": "the text"}, []string{"text"})}, - }}, - // It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which - // the holder renders in its own grammar. A module that knows something about the machine — the - // power module its battery — gives the block; the bar knows no machine's hardware. - {Name: mcat.BarSeat, Scope: mcat.ScopeNode, Decision: decided, - Receives: []mcat.Receivable{{Kind: mcat.BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}}, - {Name: mcat.CompositorSeat, Scope: mcat.ScopeNode, Decision: decided}, - {Name: mcat.SecretServiceSeat, Scope: mcat.ScopeNode, Decision: decided}, - } -} - -// withEnum narrows one string property of a schema to the values it may take, so a caller is told -// the choices by the schema rather than by a refusal. -func withEnum(s map[string]any, property string, values ...string) map[string]any { - props := s["properties"].(map[string]any) - p := props[property].(map[string]any) - p["enum"] = values - return s -} - -// barShows is what a contributed block may show, and the options each takes — the bar-neutral -// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template -// renders it (the shape's examples) before it is accepted. -var barShows = map[string]map[string]barOption{ - // The machine's battery: its charge, and how long it lasts or takes to fill. - "battery": {"device": {kind: "text"}}, - // One line a command prints, run every interval seconds: what the bar cannot know itself. - "command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}}, - // A value the contributor keeps on the bus (novox/hq ADR 0255): its state `.` and the - // key, this machine's name when absent. The value is an object whose `text` is what is shown. The - // holder is granted to read it and shows it however its bar shows a changing value; no file and - // no command passes between the two modules. - "state": {"state": {kind: "text", required: true}, "key": {kind: "text"}}, -} - -// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own. -func barBlockReads(data map[string]any) []mcat.StateRead { - if data["shows"] != "state" { - return nil - } - options, _ := data["options"].(map[string]any) - s, ok := options["state"].(string) - if !ok || s == "" { - return nil - } - key, _ := options["key"].(string) - return []mcat.StateRead{{Read: s, Key: key}} -} - -type barOption struct { - kind string // text, or seconds (a whole number from 1 to 86400) - required bool -} - -// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order, -// what it shows, and that kind's options. -// -// - bar: top or bottom; -// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the -// clock) — the two places every holder keeps for contributed blocks; -// - order: 0–99 within the place, 50 when absent; -// - shows: battery or command; options: what that kind takes. -func barBlockShape() *mcat.Shape { - return &mcat.Shape{ - Text: true, - Places: []mcat.PlaceField{{Field: "bar", Values: []string{"bottom", "top"}}, - {Field: "place", Values: []string{"resources", "status"}}}, - Order: "order", - Check: barBlockProblems, - Reads: barBlockReads, - Examples: []map[string]any{ - {"bar": "bottom", "place": "status", "shows": "battery"}, - {"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}}, - {"bar": "bottom", "place": "status", "shows": "command", - "options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}}, - {"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}}, - {"bar": "bottom", "place": "status", "shows": "state", - "options": map[string]any{"state": "power.draw", "key": "a-machine"}}, - }, - } -} - -func barBlockProblems(data map[string]any) []string { - var problems []string - for field := range data { - if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) { - problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field)) - } - } - shows, _ := data["shows"].(string) - takes, known := barShows[shows] - if !known { - return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"], - strings.Join(sortedKeys(barShows), ", "))) - } - options := map[string]any{} - if raw, has := data["options"]; has { - o, ok := raw.(map[string]any) - if !ok { - return append(problems, "a block's options are an object") - } - options = o - } - for name, v := range options { - opt, ok := takes[name] - if !ok { - problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name, - orNothing(sortedKeys(takes)))) - continue - } - switch opt.kind { - case "text": - if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" { - problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name)) - } - case "seconds": - if _, whole := wholeIn(v, 1, 86400); !whole { - problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name)) - } - } - } - for _, name := range sortedKeys(takes) { - if _, has := options[name]; takes[name].required && !has { - problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name)) - } - } - return problems -} diff --git a/internal/compose/resolution/health_seats.go b/internal/compose/resolution/health_seats.go deleted file mode 100644 index 78fa348..0000000 --- a/internal/compose/resolution/health_seats.go +++ /dev/null @@ -1,104 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// healthDecision is the record that made the four seats. -const healthDecision = "novox/hq ADR 0564" - -// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12). -func conditionsVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " + - "raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " + - "the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " + - "lately.", - Input: schema(map[string]string{ - "key": "a condition's key: that one whole; with history, only its transitions", - "scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", - "severity": "only urgent, or only warning", - "machine": "only those about this machine", - "history": "\"true\": every raising, change, silence and clearing lately, oldest first", - "days": "with history: how many days back (default 7, at most 90)", - }, nil, "history")}, - {Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " + - "status, and the silence is kept as its transition in history.", - Input: schema(map[string]string{ - "key": "the condition's key", - "for": "how long: 30m, 4h, 2d; at most 7d", - "why": "why, required and recorded", - "cause": "the cause in a word (the condition's kind when absent)", - }, []string{"key", "for", "why"})}, - {Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " + - "conditions: the recovery of a keeper that missed a change.", - Input: schema(map[string]string{}, nil)}, - } -} - -// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12). -func watchdogsVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " + - "its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " + - "expiry wrote.", - Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)}, - {Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " + - "when and the words of its finding.", - Input: schema(map[string]string{"machine": "only those on this machine"}, nil)}, - {Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " + - "those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", - Input: schema(map[string]string{"machine": "one machine"}, nil)}, - {Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " + - "consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " + - "whole. With deliver or drop, a hand act with why.", - Input: schema(map[string]string{ - "id": "a dead letter's id, as the list gives it: that one whole, with its message", - "consumer": "a consumer's name, or .: only what that one gave up on", - "limit": "how many to list, newest first (default 50); only when listing", - "deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", - "drop": "a dead letter's id: drop it for good (needs why)", - "why": "with deliver or drop: why, required and recorded", - "cause": "with deliver or drop: the cause in a word (dead-letter when absent)", - }, nil)}, - {Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " + - "the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", - Input: schema(map[string]string{}, nil)}, - } -} - -// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6). -func selfCheckVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " + - "(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " + - "probe's verdict whole. Runs every five minutes on its own; each failure is a finding.", - Input: schema(map[string]string{ - "run": "\"true\": run every probe now and answer the verdict", - "probe": "one probe's id (D6): its verdict alone, with every finding", - }, nil, "run")}, - {Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.", - Input: schema(map[string]string{}, nil)}, - } -} - -// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8). -func healersVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " + - "budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.", - Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)}, - {Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.", - Input: schema(map[string]string{ - "key": "the condition's key", - "why": "why now, kept with the act", - }, []string{"key", "why"})}, - {Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " + - "what it tests. A drill is never a repair: no healer is wanted for it however often it is run.", - Input: schema(map[string]string{ - "what": "what was done on purpose, in a line", - "why": "what the drill tests", - "condition": "the key of the condition the drill is meant to raise, if any (optional)", - }, []string{"what", "why"})}, - } -} diff --git a/internal/compose/resolution/holdings_test.go b/internal/compose/resolution/holdings_test.go index b0396fe..103d133 100644 --- a/internal/compose/resolution/holdings_test.go +++ b/internal/compose/resolution/holdings_test.go @@ -16,12 +16,12 @@ func busSeatDelivering(t *testing.T, delivers string) { func oldBroker() mcat.Manifest { return mcat.Manifest{Module: "old-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}}, - Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}} + Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh, Serves: brokerVerbs()}}} } func newBroker() mcat.Manifest { return mcat.Manifest{Module: "new-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}}, - Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}} + Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh, Serves: brokerVerbs()}}} } // Nothing on record: exactly the old rule. One claimant holds; two are refused. @@ -108,3 +108,16 @@ func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) { t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0]) } } + +// brokerVerbs are the verbs the seat mesh-broker promises its holder, as the contracts define them: a holder names every one +// (ADR 0132), so a fixture holder does (issue 1966 ENG-3). +func brokerVerbs() []string { + seat, _ := mcat.SeatNamed("mesh-broker") + var out []string + for _, v := range seat.Serves { + if !v.Optional { + out = append(out, v.Name) + } + } + return out +} diff --git a/internal/compose/resolution/reads.go b/internal/compose/resolution/reads.go index ce313bd..66be41c 100644 --- a/internal/compose/resolution/reads.go +++ b/internal/compose/resolution/reads.go @@ -10,64 +10,6 @@ import ( mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the -// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in -// every answer it cuts. -func readsVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " + - "(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " + - "file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " + - "holds no secret: a component it may not reach says so. At most 200 paths in one call.", - Input: schema(map[string]string{ - "paths": "one absolute path, or several separated by line breaks", - "follow": "\"true\": also say where the path leads once every link is followed", - }, []string{"paths"}, "follow"), - Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}}, - {Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " + - "link is followed, and whether that exists.", - Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}), - Replaces: []string{"readlink", "readlink -f", "realpath"}}, - {Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " + - "link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " + - "is refused by name.", - Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}), - Replaces: []string{"ls", "ls -la"}}, - {Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " + - "(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " + - "kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " + - "number said. /proc and /sys are read as far as the holder's account may.", - Input: schema(map[string]string{ - "path": "an absolute path to a file", - "from": "the first line to answer, counted from 1 (optional)", - "lines": "how many lines to answer (optional)", - "bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", - }, []string{"path"}), - Replaces: []string{"cat", "head", "tail", "less"}}, - {Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " + - "every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " + - "8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " + - "binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " + - "secret is withheld. The answer says which bound it met.", - Input: schema(map[string]string{ - "text": "the fixed string to find", - "path": "an absolute path to a file or a directory", - "depth": "how many directories deep, up to 8 (optional)", - "matches": "the most matches to answer, up to 500 (optional)", - "case": "\"false\": match whatever the case (optional)", - }, []string{"text", "path"}, "case"), - Replaces: []string{"grep", "grep -r", "grep -F"}}, - {Name: "account", Description: "One account or group by name or number, as this node's account files say: " + - "an account's number, group, home, shell and every group it is in; a group's number and members. With " + - "neither, every account and group. Never a password field.", - Input: schema(map[string]string{ - "name": "an account's name or number (optional)", - "group": "a group's name or number (optional)", - }, nil), - Replaces: []string{"getent passwd", "getent group", "id", "groups"}}, - } -} - // secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310 // §5): the directory of every module's own secret, every credential file a module is given and every grant // directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed diff --git a/internal/compose/resolution/seat_into.go b/internal/compose/resolution/seat_into.go index 4a08686..c78ec5c 100644 --- a/internal/compose/resolution/seat_into.go +++ b/internal/compose/resolution/seat_into.go @@ -33,7 +33,10 @@ var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`) var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`) // reachedSeats is every seat ${seat:…:reach} answers for. -var reachedSeats = map[string]bool{"mesh-artifact-store": true} +// +// Both names of the store are answered with one address (ADR 0605, issue 1966 ENG-3): the seat it was held through and the +// seat the registries' holder serves the same port as. +var reachedSeats = map[string]bool{"mesh-artifact-store": true, "container-image-registry": true} // seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's // holder — in a file's content, and in a value of a container's or a process's environment. The diff --git a/internal/compose/resolution/seat_into_test.go b/internal/compose/resolution/seat_into_test.go index 77f6cb6..ddb7246 100644 --- a/internal/compose/resolution/seat_into_test.go +++ b/internal/compose/resolution/seat_into_test.go @@ -110,6 +110,17 @@ func TestASeatsReachIsWhereThisMachineReachesItsHolder(t *testing.T) { if got := process["env"].(map[string]any)["REGISTRY"]; got != "anchor.internal:5100" { t.Fatalf("an environment value was filled with %q", got) } + + // And the seat the registries' holder serves the same port through answers the same address (ENG-3). + both := map[string]any{"type": "process", "name": "p", "env": map[string]any{ + "OLD": "${seat:mesh-artifact-store:reach}", "NEW": "${seat:container-image-registry:reach}"}} + with.SeatReach["container-image-registry"] = "anchor.internal:5100" + if err := seatInto(both, "x", with); err != nil { + t.Fatal(err) + } + if env := both["env"].(map[string]any); env["OLD"] != "anchor.internal:5100" || env["NEW"] != "anchor.internal:5100" { + t.Fatalf("the two names of the store answer %v", env) + } } // With no holder reachable, the runtime is not told to trust "": the member is dropped, and the diff --git a/internal/compose/resolution/seats.go b/internal/compose/resolution/seats.go index 5e1beb8..1420499 100644 --- a/internal/compose/resolution/seats.go +++ b/internal/compose/resolution/seats.go @@ -4,408 +4,10 @@ import ( mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the -// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is -// written; the store's table is seeded from it and thereafter is the live, editable copy. -// -// In the order a person reads it: the mesh's own, then a node's. -var defaultSeats = append([]mcat.Seat{ - // The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable, - // served by whichever module holds the seat with tools of these names. - {Name: "mesh-store", Scope: mcat.ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079", - Serves: []mcat.Verb{ - {Name: "databases", Description: "Every database the store holds, with its on-disk size.", - Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}}, - {Name: "query", Description: "One read-only statement against one database the store holds.", - Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"}, - []string{"database", "sql"}), Replaces: []string{"psql"}}, - }}, - // **Delivers the mesh's own bus, not `amqp`.** Those were the same word until - // ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is - // the mesh's own transport. ADR 0128 then made that connection something a module requires - // rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient - // connection would mint a credential for each. - {Name: mcat.BrokerSeat, Scope: mcat.ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"}, - // The vault: the controller seals every minted credential with what it provides, which is the - // test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a - // second claimant, refused by name, rather than a candidate for a pin. - // - // Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an - // asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate` - // take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from - // the controller alone, which carries the value sealed to a key the vault made for the call and cannot open - // it. All optional until the vault that runs serves them (design 33 §7). - {Name: "mesh-vault", Scope: mcat.ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161", - Serves: []mcat.Verb{ - {Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " + - "sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " + - "bus proved it. Never the value in the clear, and never to a private key.", - Input: schema(map[string]string{"name": "the secret's name", - "public_key": "the asker's X25519 public key as SPKI PEM — never a private key"}, - []string{"name", "public_key"})}, - {Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " + - "controller alone: name the secret and get a call and a key made for it, then send the call and the " + - "value sealed to that key. The vault alone opens it, and never remakes an accepted credential.", - Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret", - "module": "the module, for a module's own secret", "name": "the secret's name in the module", - "provider": "the provider's machine, for a provision's credential", "provision": "the provision", - "by": "who gave it at the terminal", - "origin": "issued for a bus account the controller minted for a core or moved module and hands " + - "at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)}, - {Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " + - "from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " + - "0563 §8): the ask is recorded in the vault's store and bounded before the prompt " + - "opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", - Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for", - "module": "the module", "name": "the secret's name in the module", - "desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller", - "outcome": "instead of sealed: how the prompt ended"}), nil)}, - {Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " + - "provision, from the controller alone, which asks the provider's tool with a key the vault made for " + - "the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " + - "each as that holder's own; the same value again writes nothing.", - Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision", - "provider": "the provider's machine", "module": "the provider's module"}), nil))}, - {Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " + - "software issued and for an accepted credential. Never answers the value.", - Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module", - "name": "the secret's name in the module", "provider": "the provider's machine", - "provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)}, - // The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what - // was sealed to its machine, which the controller cannot open; the controller copies its record of asks. - {Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " + - "from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " + - "and generation and get a call and a key made for it, then send the call, the value sealed to that " + - "key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " + - "back sealed to that key.", - Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine", - "module": "its module", "name": "its name in the module", - "provider": "a pair credential: the provider's machine", "provision": "the provision", - "consumer": "the consumer's machine", "consumer_module": "the consuming module", - "local": "the credential's local name, when it has one", - "origin": "made, accepted or issued, as the controller's store says", - "generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)", - "read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)}, - // A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786). - {Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " + - "own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " + - "first or changed key is recorded and the machine's core secrets are said changed; the same key " + - "again changes nothing.", - Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)}, - // A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547 - // §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says. - {Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " + - "alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " + - "credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " + - "credential the vault does not hold is named missing; never a value in the clear.", - Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{ - "type": "array", "items": map[string]any{"type": "object"}, - "description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " + - "provision, consumer, consumer_module, provider, local}"}}}}, - {Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " + - "into the vault's, as they are and once, from the controller alone. No value is in any of them.", - Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{ - "type": "array", "items": map[string]any{"type": "object"}, - "description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}}, - {Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " + - "ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " + - "controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", - Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})}, - {Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " + - "kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " + - "node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " + - "a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " + - "redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " + - "and the signer's fingerprint, and says signed; never a key.", - Input: schema(map[string]string{"kind": "node-certificate or enrolment", - "node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", - "serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented", - "reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})}, - // Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's - // manifest names for these two. - {Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " + - "issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " + - "written as the bus user enrol.. Answers only what the joining machine must trust (the bus's " + - "address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " + - "sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " + - "dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " + - "carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", - Input: withObject(schema(map[string]string{"node": "the node the token is for", - "expires": "when the token expires (RFC 3339), as the enrolment's key says", - "sealed": "\"true\": answer the token sealed to the operator key", - "drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"), - "tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+ - "public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", - map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port", - "address": "the joining node's tunnel address", - "key": "the joining machine's own tunnel public key, as given at issue", - "range": "the private network's range, a prefix"})}, - {Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node., sealed " + - "to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " + - "account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " + - "node's node-logins key naming node.. Asked by mesh-enrolment alone. Never the value in the clear.", - Input: schema(map[string]string{"node": "the joining node", - "sealing_key": "the sealing key's public half the node presented, which the account is sealed to", - "keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)", - "retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")}, - // A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus - // node last; the state node-logins says where each node stands. - {Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " + - "answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " + - "its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " + - "rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " + - "node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.", - Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence", - "act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})}, - {Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " + - "asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " + - "The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " + - "without it.", - Input: schema(map[string]string{}, nil)}, - {Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " + - "the new login and its value dropped, the old one kept; a node that switched switches back first.", - Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})}, - }}, - // The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets - // mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10). - // Optional until the mesh-config that runs serves it (design 33 §7). - {Name: "mesh-config", Scope: mcat.ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565", - Serves: []mcat.Verb{ - {Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " + - "mesh config or node config for a predefined key, or a module's own value on one node, each set " + - "with why, validated, and naming each module on each node whose value moves.", - Input: configInput()}, - }}, - // The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which - // domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect, - // which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads - // them. Optional until the mesh-provisioner that runs serves it (design 33 §7). - {Name: "mesh-provisioner", Scope: mcat.ScopeMesh, Decision: "novox/hq ADR 0349", - Serves: []mcat.Verb{ - {Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " + - "(which domain answers which kind of placeholder), the needs each module on each node recorded, what " + - "is applied where, or the items of every collection, narrowed by node and module. Reads only.", - Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect", - "node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)}, - }}, - // Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as - // an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes — - // images and archives, by digest — which is why the provision is the artifact store and not an - // image registry. - {Name: "mesh-artifact-store", Scope: mcat.ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, - // The catalog (novox/hq ADR 0604): the SDK's entry with its four verbs, so the copies of the table agree. - sdkSeat(mcat.CatalogSeat), - // The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every - // machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act. - // What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it. - {Name: mcat.DeliverySeat, Scope: mcat.ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"}, - // Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by - // a proposal the nodes answer and the operator approves. Its states and events are the holder module's. - {Name: mcat.PlacementSeat, Scope: mcat.ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"}, - // The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's. - {Name: mcat.CheckerSeat, Scope: mcat.ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"}, - // The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the - // watchman's watchdogs, the self-check and the healers. - {Name: mcat.ConditionsSeat, Scope: mcat.ScopeMesh, Decision: healthDecision, - Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()}, - {Name: mcat.WatchdogsSeat, Scope: mcat.ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()}, - {Name: mcat.SelfCheckSeat, Scope: mcat.ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()}, - {Name: mcat.HealersSeat, Scope: mcat.ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()}, - // The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's - // policy and the bus step at the assigner, and the hand-act log. - {Name: mcat.WantsSeat, Scope: mcat.ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()}, - {Name: mcat.HandActsSeat, Scope: mcat.ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()}, - // Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment. - {Name: mcat.EnrolmentSeat, Scope: mcat.ScopeMesh, Decision: enrolmentDecision, Emits: mcat.EnrolmentEvents, Serves: enrolmentVerbs()}, - // The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go). - {Name: mcat.ArtifactSweepSeat, Scope: mcat.ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"}, - Serves: artifactSweepVerbs()}, - // Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a - // delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight. - // They keep their names until that migration is done deliberately, apart from the node-* pass. - {Name: "npm-package-registry", Scope: mcat.ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"}, - {Name: "git", Scope: mcat.ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"}, - // **A build node's work, one seat per build language** (novox/hq ADR 0525 §2, §10; ADR 0536 §4; issue 1887). The - // build module of each language claims its seat on every node it runs on; the builder and the checker put their - // asks on the seat's accepted kinds, which the broker derives as one work queue with a shared worker per kind, and - // each holder pulls delivery first, then check, then recheck, at most its node's build capacity at once. Its verbs - // say what the node runs and pause or resume it there: every holder pulls from the same worker, so a node's pause is - // its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace - // node-build-agent, retired with build-agent (ADR 0524 §5). - {Name: "node-build-go", Scope: mcat.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"), - Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, - {Name: "node-build-node", Scope: mcat.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"), - Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, - {Name: "node-build-image", Scope: mcat.ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"), - Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, - // **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and - // every node and container asks them and nothing else. Replicated since ADR 0223: held on more - // than one machine, each answering the same names from the same roster, and every machine's - // resolver file lists every holder — its own first — and no public resolver, so whichever answers - // first gives the one answer. Delivers what a machine's resolver configuration requires, so that - // requirement resolves to a holder wherever they are placed. - {Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Delivers: "wildcard-resolution", Replicated: true, - Decision: "novox/hq ADR 0194, ADR 0223"}, - // **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes - // /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts - // file as the operator's, changed through these three verbs on that machine alone. The controller - // holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an - // alias on a mesh that knew it. - {Name: "node-hostname", Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223", - // The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287). - Paths: []mcat.SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}}, - Serves: []mcat.Verb{ - {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + - "the operator's, or the block of the module or tool that writes it.", - Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}}, - {Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " + - "/etc/hosts — a name for this machine's own programs, not the mesh's.", - Input: schema(map[string]string{"address": "the IPv4 or IPv6 address", - "names": "the names for it, separated by spaces"}, []string{"address", "names"}), - Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}, - {Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " + - "lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", - Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"}, - []string{"name"}), Replaces: []string{"sed -i /etc/hosts"}}, - }}, - // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list - // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all - // four; the jails themselves are composed from the modules the machine runs (to-be 31). - {Name: "node-intrusion-prevention", Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0121", - Serves: []mcat.Verb{ - {Name: "status", Description: "Every jail on this machine with how many it is watching and " + - "holding now, and the totals since the jail started; one jail's detail when named.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}}, - {Name: "banned", Description: "Every address banned on this machine right now, with the jail " + - "that holds it and when the ban ends.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}}, - {Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " + - "operator's act on the live ban list, which the mesh never writes itself.", - Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}), - Replaces: []string{"fail2ban-client set banip"}}, - {Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.", - Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}), - Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}}, - }}, - // The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever - // filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did - // not write. Every holder serves all three; what differs by filter is the holder's own tools. - {Name: "node-packet-filter", Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0121", - Serves: []mcat.Verb{ - {Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " + - "ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " + - "chain when asked.", - Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)", - "chain": "one chain of that table (optional)"}, nil), - Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}}, - {Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " + - "and answer with the mesh's table as loaded.", - Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}}, - {Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " + - "host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " + - "DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " + - "active found firewall's chains. An operator's act, by name, never a flush.", - Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}), - Replaces: []string{"nft delete", "iptables -X"}}, - }}, - // The machine's service manager (novox/hq ADR 0177). The host applies every declared unit, - // system or user scope; the holder answers questions and operator acts about them, each verb - // taking the unit and an optional scope. The holder runs nothing of its own: its verbs are - // served by the node tools runtime (ADR 0175). - {Name: mcat.ServiceManagerSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0177", - Serves: serviceManagerVerbs()}, - // The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on - // it being held on its node, as one declaring a `service` depends on node-service-manager: the - // mesh's word for "something on this machine answers for installing", where a capability only - // says the software is there. No verbs yet — the seat says who answers, and what may be asked - // of it is decided when someone needs to ask. - {Name: mcat.PackageManagerSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0207"}, - // The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module - // declaring a `container` depends on it being held on its node. Its verbs, and the host creating - // containers through its holder, wait for ADR 0166's acceptance — seeded without them so the - // dependency has a seat to name and the runtime's module has one to claim. - {Name: mcat.ContainerRuntimeSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"}, - // The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and - // every module contributes to it. No verbs — the seat says who places the environment's files, - // and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing - // which module wrote it. - {Name: mcat.EnvironmentSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0203"}, - // The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a - // bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the - // machine's templates. It serves no verb an agent calls. - {Name: "node-provisioner", Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0349"}, - // The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176): - // the mesh's, so a second shell module claims the seat rather than declaring a second one, and - // the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller - // may call where the machine serves it (optional: ADR 0268); the holder places every module's shell - // code in its slots. - {Name: mcat.LoginShellSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0204", - Serves: loginShellVerbs()}, - // A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the - // code modules contribute for the power moments, and publishes the machine's power states as - // its events. Every machine has one — every machine boots and shuts down. No verbs yet. - {Name: mcat.PowerSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0211"}, - // The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager - // does not — a laptop's vendor keys — run by one module per machine, which owns its - // configuration. Every other module with keys contributes trigger lines to it. - {Name: mcat.HotkeysSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0212", - Receives: []mcat.Receivable{{Kind: "trigger", Comment: "#"}}}, - // The machine's message bus (novox/hq ADR 0215): its holder owns the D-Bus implementation and its - // system service, never restarts it live, and publishes curated events about it, never its traffic. - // It receives nothing yet: packages ship their own policies. - {Name: mcat.MessageBusSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0215"}, - // The machine's backups (novox/hq ADR 0214, to-be 43): its holder keeps nightly restore points of - // the data every module on the machine declares, on the machine, against mistakes rather than - // disasters. A module contributes `backup` lines — what to run to take a consistent copy, and - // which of its directories to keep. - {Name: mcat.BackupSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0214", - Serves: backupVerbs(), - // `backup` is what to run and which paths to keep; `data` every item a module declares, with its - // class, for the holder to measure (novox/hq ADR 0233). Both derived from modules' data sections. - Receives: []mcat.Receivable{{Kind: mcat.BackupKindBackup, Comment: "#", Dirs: true}, {Kind: mcat.BackupKindData, Comment: "#", Dirs: true}}}, - // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client - // model change, not a rename, so it stays until that is built. - {Name: "the-private-network", Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0110"}, - // The program that manages the machine's own network. It delivers nothing: its holder keeps the - // manager and the mesh from contradicting each other — the private network's interface left - // alone — and writes the machine's resolver file itself, because the manager is what would - // otherwise rewrite it (novox/hq ADR 0223, which retired node-resolver-config into this seat). - // It never declares a link, an address or a wireless network, because the link is the only - // channel a fix could arrive on. A seat - // rather than a condition in the resolver's module, so a machine running two managers is - // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). - // - // Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the - // resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers - // the manager knows for it — the same for every holder, so a question about a machine's names is asked - // the same way whatever manages its network. Optional until both holders serve them. - // - // It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287, - // ADR 0247). - {Name: "node-uplink", Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(), - Paths: []mcat.SeatPath{{Path: mcat.ResolverFile, StepsBackFor: mcat.ResolverSeat}}}, - // The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a - // VPN client whose domains must go to its own servers while every other name goes to the mesh's. - // Where it is held, its holder writes the resolver file and the uplink's holder steps back from it - // (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link. - {Name: mcat.ResolverSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(), - Paths: []mcat.SeatPath{{Path: mcat.ResolverFile}}}, - // A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server - // exports a machine's folders to the private network and provides each as `nfs-share`; the holder of - // node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go). - {Name: mcat.NFSServerSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()}, - {Name: mcat.MountsSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()}, - // A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds - // no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool. - {Name: mcat.ReadsSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()}, - // A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned - // on every node, then node-reads goes (ADR 0394 §9). - {Name: mcat.FilesSeat, Scope: mcat.ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()}, - // The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module. - engineSeat(), -}, - // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. - graphicalSessionSeats()...) +// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the fallback when it has none +// (novox/hq ADR 0122). It is mesh-contracts' generated list, as mesh-sdk compiles it, taken whole: the node-engine keeps no copy of +// a seat's verbs or events (design 26, ADR 0608 §5; issue 1966 ENG-3). +var defaultSeats = mcat.DefaultSeats() // seats is the working set the lookups read. It starts as the compiled defaults and is replaced by // what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a @@ -467,195 +69,3 @@ func HolderAmong(provision string, providers []mcat.Provider, held []mcat.Held) } return mcat.Provider{}, false } - -// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR -// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an -// optional scope — "system" when absent, "user" for the operator account's own manager — so a -// caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers -// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional -// (Verb.Optional) until every holder serves them (ADR 0246). -func serviceManagerVerbs() []mcat.Verb { - scoped := func(more map[string]string, required []string) map[string]any { - props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"} - for k, v := range more { - props[k] = v - } - return schema(props, required) - } - unit := map[string]string{"unit": "the unit's name, as the service manager knows it"} - return []mcat.Verb{ - {Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", - Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}}, - {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}}, - {Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}}, - {Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}}, - {Name: "restart", Description: "Restart one unit.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}}, - {Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}}, - {Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}}, - // **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the - // minutes it happened in, and with no window on the verb a person reached for a shell. Every - // argument is the holder's to validate — passed to journalctl as one word of its own, never through - // a shell — and what the unit printed of a secret is redacted before it is answered. - {Name: "journal", Description: "The last lines of one unit's journal (at most 2000), in a time window and " + - "narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as " + - "[redacted: ].", - Input: scoped(map[string]string{ - "unit": unit["unit"], - "lines": "how many lines from the end of what matches (default 100, at most 2000)", - "since": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)", - "until": "the window's end, in the same forms (optional; now when absent)", - "match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", - "priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", - }, []string{"unit"}), - Replaces: []string{"journalctl"}}, - // What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers - // it, so a caller asks every machine the same way. **Optional while its holders catch up**: the - // systemd module running today serves it as its own systemd_failed, and a required verb would - // refuse it before the version serving `failed` could be delivered. - {Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " + - "account's; a manager that does not answer is reported with its error, never as nothing failed.", - Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil), - Replaces: []string{"systemctl --failed"}}, - // **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose - // file is gone stays failed in its manager until the record is reset, and what keeps asking for it - // is a dependency or an enable link no verb could show. Required since every holder serves them - // (ADR 0246 step 3). - {Name: "reset-failed", Description: "Clear one unit's failed record in its manager — " + - "its failed state and its start-limit count — and answer its state after. It removes no file and " + - "starts nothing.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}}, - {Name: "wanted-by", Description: "What wants, requires or triggers one unit, read-only: the " + - "manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " + - "every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " + - "each with where it points and whether it dangles — the account's own directory included in user scope.", - Input: scoped(unit, []string{"unit"}), - Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy", - "ls ~/.config/systemd/user/*.wants"}}, - // **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable` - // leaves a link whose unit file is gone, so the manager keeps asking for the unit. Required since - // every holder serves it (ADR 0246 step 3). - {Name: "unlink-dangling", Description: "Remove the dangling enable links named for one " + - "unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " + - "configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " + - "the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " + - "removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " + - "runtime placed, is left and said.", - Input: scoped(map[string]string{"unit": unit["unit"], - "why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}), - Replaces: []string{"rm ~/.config/systemd/user/*.wants/", "unlink /etc/systemd/system/*.wants/"}}, - } -} - -// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers -// about what the machine resolves through and over which links, whatever program manages its network. -func uplinkVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " + - "domains and options it lists, whether it is the file the mesh declares, and when it is not, who " + - "wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " + - "in its place, a known writer running.", - Input: schema(nil, nil), Optional: true}, - {Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " + - "the default route leaves through it, and the resolvers and search domains the network manager " + - "knows for it — a VPN's tunnel included.", - Input: schema(nil, nil), Optional: true}, - } -} - -// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now, -// and restore beside the live data — never over it. -func backupVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " + - "its last good night, how many restore points are kept and the repository's size.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}}, - {Name: "now", Description: "Take a backup now, of one module or of every module on this " + - "machine — before a migration, a retirement or anything else that could go wrong.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}}, - {Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " + - "data, never over it: each directory as .restored-. Swapping it in is a " + - "person's act. Lists the restore points when none is named.", - Input: schema(map[string]string{ - "module": "the module", - "snapshot": "the restore point (from `backed-up`; the newest when omitted)", - "path": "one of the module's directories (all of them when omitted)", - }, []string{"module"}), Replaces: []string{"restic restore"}}, - } -} - -// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run -// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit -// so a hung command answers rather than times the caller out. -// -// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can -// become root without a person, so a machine may withhold it: the control-node does, through the -// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark -// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its -// silence on the bus is not judged — a holder withholding it serves nothing on the seat. -func loginShellVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " + - "non-interactive login shell in its home; answers with what it printed and how it exited.", - Input: schema(map[string]string{ - "command": "the command line, as you would type it", - "timeout_seconds": "give up after this long, at most 25 (default 20)", - }, []string{"command"})}, - } -} - -// buildAskKinds are a build seat's accepted kinds, in the order a holder pulls them (ADR 0536 §4; mesh-sdk -// delivery.AskKinds). -func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} } - -// buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10). -func buildSeatVerbs(language string) []mcat.Verb { - return []mcat.Verb{ - {Name: "current", Description: "The " + language + " asks this node runs now (each ask's id, kind, module and when it " + - "started), its build capacity, and whether it is paused, taking no new ask.", - Input: schema(map[string]string{}, nil)}, - {Name: "pause", Description: "Take no new " + language + " ask on this node until resumed; an ask running here " + - "ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the " + - "lasting way to keep a node from building.", - Input: schema(map[string]string{}, nil)}, - {Name: "resume", Description: "Take " + language + " asks again on this node.", - Input: schema(map[string]string{}, nil)}, - } -} - -// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed. -// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a -// value for each, which the vault keeps as that holder's own and hands to it alone. -func withHolders(input map[string]any) map[string]any { - props, _ := input["properties"].(map[string]any) - props["holders"] = map[string]any{"type": "array", - "description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " + - "for each, kept as that holder's own and handed to it alone", - "items": map[string]any{"type": "object", "properties": map[string]any{ - "node": map[string]any{"type": "string", "description": "the holder's machine"}, - "module": map[string]any{"type": "string", "description": "the consuming module"}}}} - return input -} - -func vaultIntake(first map[string]string) map[string]string { - out := map[string]string{"call": "the second step: the call the first step answered", - "sealed": "the second step: the value sealed to the call's key — never the value"} - for k, v := range first { - out[k] = v - } - return out -} - -// sdkSeat is the SDK's entry for a seat, taken whole, so this copy of the table cannot disagree with it. -func sdkSeat(name string) mcat.Seat { - seat, ok := mcat.SeatNamed(name) - if !ok { - panic("the SDK's seat table holds no seat " + name) - } - return seat -} diff --git a/internal/compose/seats_test.go b/internal/compose/seats_test.go index a07e0c2..d39cbd9 100644 --- a/internal/compose/seats_test.go +++ b/internal/compose/seats_test.go @@ -10,7 +10,20 @@ import ( func aBrokerClaiming(module string) catalogue.Manifest { return catalogue.Manifest{Module: module, Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Serves: brokerVerbs()}}} +} + +// brokerVerbs are the verbs the seat mesh-broker promises its holder, as the contracts define them: a holder names every one +// (ADR 0132), so a fixture holder does (issue 1966 ENG-3). +func brokerVerbs() []string { + seat, _ := catalogue.SeatNamed("mesh-broker") + var out []string + for _, v := range seat.Serves { + if !v.Optional { + out = append(out, v.Name) + } + } + return out } // withSeat is mesh-controller's withBusSeat: a single mesh seat, mesh-broker, for the test. -- 2.54.0 From c93feeb30f750750a19f1570701f11fdd5e6b801 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:15:26 +0100 Subject: [PATCH 08/73] ENG-9: the node seat's read verb pull-waits answers which failed applies wait on which store's holder and the last retry with its trigger (design 51, issue 1966; needs the verb in the node-engine seat's definition) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- cmd/mesh-host/engineseat.go | 11 +++++++++++ cmd/mesh-host/main.go | 1 + internal/agent/agent.go | 28 ++++++++++++++++++++++++++++ internal/agent/agent_test.go | 8 ++++++++ internal/link/handacts.go | 10 ++++++++++ internal/link/handover_test.go | 5 +++++ 6 files changed, 63 insertions(+) diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index f7c8b88..c6c79ff 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -284,6 +284,17 @@ const compositionReadFor = 20 * time.Second // compositionFrom answers the verb composition from the node agent that runs now (issue 774); refused in words while // none does. +// pullWaitsFrom answers the verb pull-waits from the agent running now (ENG-9). +func pullWaitsFrom(current *agentNow) func() (any, error) { + return func() (any, error) { + ag := current.get() + if ag == nil { + return nil, errors.New("this engine's node agent has not started, so it waits on no store yet") + } + return map[string]any{"waits": ag.PullWaits()}, nil + } +} + func compositionFrom(current *agentNow) func(module, digest string, diff bool) (any, error) { return func(module, digest string, diff bool) (any, error) { ag := current.get() diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 0d03f6b..b6626fb 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1015,6 +1015,7 @@ func runLink(ctx context.Context, opts options) error { go reconcileActs(ctx, queue.Acts) current := &agentNow{} queue.Acts.Composition = compositionFrom(current) + queue.Acts.PullWaits = pullWaitsFrom(current) queue.Acts.Compose = composeFrom(current, &composerNow) queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow) queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5a6fc1b..8157dec 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1932,3 +1932,31 @@ func (a *Agent) Settled(holder string) { a.askPullAgain(module, asks[i]) } } + +// PullWaitView is one failed apply that waits on the holder of a store, as the engine's read verb pull-waits shows it +// (ENG-9, design 51): the module, the image and the seat it came through, whose settling it waits on, when the pull began, +// how many times that holder has settled since this engine started and when last, and what triggered the last retry. +type PullWaitView struct { + Module string `json:"module"` + Image string `json:"image"` + Seat string `json:"seat,omitempty"` + Holder string `json:"holder"` + Started time.Time `json:"pull-started"` + Settled int `json:"holder-settled"` + SettledAt time.Time `json:"holder-last-settled,omitzero"` + LastRetry string `json:"last-retry,omitempty"` + LastRetryAt time.Time `json:"last-retry-at,omitzero"` +} + +// PullWaits are the failed applies that wait on a store's holder, by module. +func (a *Agent) PullWaits() []PullWaitView { + a.mu.Lock() + defer a.mu.Unlock() + out := []PullWaitView{} + for module, pw := range a.pulls { + out = append(out, PullWaitView{Module: module, Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Started: pw.Started, + Settled: a.settledSeq[pw.Holder], SettledAt: a.settledAt[pw.Holder], LastRetry: pw.last, LastRetryAt: pw.lastAt}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module }) + return out +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 4592d48..d33ed3a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -521,6 +521,11 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { if n := len(b.appliedList()); n != 2 { t.Fatalf("a second failure after the same settled started a further apply (%d applies)", n) } + // The read verb's view: whom it waits on, how often that holder settled, and what triggered the last retry. + if w := b.ag.PullWaits(); len(w) != 1 || w[0].Module != "web" || w[0].Holder != "distribution" || w[0].Image != "reg/x@sha256:1" || + w[0].Settled != 1 || !strings.Contains(w[0].LastRetry, "distribution settled") { + t.Fatalf("the engine says it waits on %+v", w) + } // The next settled answers once more, and the pull works: the module runs, and waits on nothing. b.mu.Lock() b.failWith = "" @@ -529,6 +534,9 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { b.ag.Settled("distribution") eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 }) eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) + if w := b.ag.PullWaits(); len(w) != 0 { + t.Fatalf("a running module still waits on %+v", w) + } b.ag.Settled("distribution") time.Sleep(50 * time.Millisecond) if n := len(b.appliedList()); n != 3 { diff --git a/internal/link/handacts.go b/internal/link/handacts.go index 3412de9..90a1cda 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -47,6 +47,9 @@ const ( ActPackages = "packages" // ActComposeCandidate composes a check's candidate on this node in shadow, read-only (novox/hq ADR 0593 §7). ActComposeCandidate = "compose-candidate" + // ActPullWaits reads the failed applies that wait on a store's holder, and the last retry with its trigger (design 51, + // issue 1966 ENG-9). + ActPullWaits = "pull-waits" ) // The states of an act. @@ -149,6 +152,8 @@ type HandActs struct { // module, applying and writing nothing; with input, also the composition input it composed from (the verb // compose-candidate, ADR 0593 §7). ComposeCandidate func(candidate string, input bool) (any, error) + // PullWaits answers which failed applies wait on which store's holder, and the last retry of each with what triggered it. + PullWaits func() (any, error) // Say says one of the seat's events, asked, done or refused; nil says nothing. Say func(event string, body any) Now func() time.Time @@ -230,6 +235,11 @@ func (h *HandActs) Call(c EngineCall) (any, error) { input = b } return h.ComposeCandidate(arg("candidate"), input) + case ActPullWaits: + if h.PullWaits == nil { + return nil, errors.New("this engine waits on no store yet: its node agent has not started") + } + return h.PullWaits() case "acts": if id := arg("id"); id != "" { return h.Act(id) diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 2c46bfe..15e9442 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -223,6 +223,10 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { return map[string]any{"module": module, "digest": digest, "diff": diff}, nil }, Compose: func(module string) (any, error) { return map[string]any{"composed": true, "retried": module}, nil }, + // ENG-9: the failed applies that wait on a store's holder. + PullWaits: func() (any, error) { + return map[string]any{"waits": []map[string]any{{"module": "web", "holder": "distribution", "last-retry": "distribution settled"}}}, nil + }, // ADR 0565 §6, §15: the node's key of the engine's state packages. Packages: func(write bool) (any, error) { key := []byte(`{"node":"n1","packages":[{"package":"curl","modules":["backup","web"],"absent":false}]}`) @@ -245,6 +249,7 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { // The recovery verb compose (issue 774) makes a pass now, with a module's apply tried again when named. {"compose", EngineToolSubject("compose", "n1"), `"composed":true,"retried":""`}, {"compose", EngineToolSubject("compose", "n1") + " " + `{"module":"route-proxy"}`, `"retried":"route-proxy"`}, + {"pull-waits", EngineToolSubject("pull-waits", "n1"), `"holder":"distribution","last-retry":"distribution settled","module":"web"`}, // The read verb packages (ADR 0565 §6) names its source; with write, what it wrote, and a key the engine has not // written is said and not an error. {"packages", EngineToolSubject("packages", "n1"), `"source":"mesh-host.packages","key":{"node":"n1"`}, -- 2.54.0 From 271de0108da1feeb3c76ecb82ff798e4a1ca2dcd Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:00:21 +0100 Subject: [PATCH 09/73] =?UTF-8?q?The=20node-engine=20serves=20manifest-rea?= =?UTF-8?q?ds=20and=20says=20manifest-reads-changed=20once=20per=20link=20?= =?UTF-8?q?(ADR=200618=20=C2=A73,=20=C2=A74,=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- cmd/mesh-host/engineseat.go | 2 +- internal/link/handacts.go | 12 ++++++++++ internal/link/handover_test.go | 44 ++++++++++++++++++++++++++++++++++ internal/link/record.go | 4 ++++ internal/link/run.go | 2 ++ internal/link/session.go | 32 +++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 1 deletion(-) diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index c6c79ff..307290d 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -255,7 +255,7 @@ func engineActs(opts options, node string, queue *link.Session, search *accounts }) return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why) }, - Say: queue.SayEngine} + ManifestReads: queue.StatedReads, Say: queue.SayEngine} asker := asks.EngineAsker(node) h.Asker = &meshasker.Client{Module: asker, State: state, Publish: queue.PublishAsk, Record: func(id string) (*stdio.StateEntry, error) { return queue.RouterRecord(asker + "." + id) }} diff --git a/internal/link/handacts.go b/internal/link/handacts.go index 90a1cda..2e70cd2 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -16,6 +16,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // The node seat node-engine's acts (novox/hq ADR 0563). @@ -50,6 +51,8 @@ const ( // ActPullWaits reads the failed applies that wait on a store's holder, and the last retry with its trigger (design 51, // issue 1966 ENG-9). ActPullWaits = "pull-waits" + // ActManifestReads reads the reader capabilities this engine states (novox/hq ADR 0618 §3). + ActManifestReads = "manifest-reads" ) // The states of an act. @@ -154,6 +157,9 @@ type HandActs struct { ComposeCandidate func(candidate string, input bool) (any, error) // PullWaits answers which failed applies wait on which store's holder, and the last retry of each with what triggered it. PullWaits func() (any, error) + // ManifestReads is what this engine states it reads of a manifest and when its record last carried them (the verb + // manifest-reads, novox/hq ADR 0618 §3). + ManifestReads func() ([]string, time.Time) // Say says one of the seat's events, asked, done or refused; nil says nothing. Say func(event string, body any) Now func() time.Time @@ -240,6 +246,12 @@ func (h *HandActs) Call(c EngineCall) (any, error) { return nil, errors.New("this engine waits on no store yet: its node agent has not started") } return h.PullWaits() + case ActManifestReads: + if h.ManifestReads == nil { + return nil, errors.New("this engine states no manifest reads yet: its node agent has not started") + } + reads, at := h.ManifestReads() + return delivery.ManifestReadsAnswer{V: delivery.Version, Node: h.Node, ManifestReads: reads, StatedAt: at}, nil case "acts": if id := arg("id"); id != "" { return h.Act(id) diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 15e9442..c884172 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -12,6 +12,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/micro" ) @@ -191,6 +192,20 @@ type handingLink struct { given chan HandOverFunc } +// readsLink is a quiet link that serves the node seat and keeps the manifest-reads-changed events said on it. +type readsLink struct { + *quietLink + said chan delivery.ManifestReadsChanged +} + +func (l readsLink) ServeEngineSeat(EngineFunc) {} +func (l readsLink) SayEngine(event string, body any) error { + if e, err := delivery.DecodeManifestReadsChanged(body.(json.RawMessage)); event == "manifest-reads-changed" && err == nil { + l.said <- e + } + return nil +} + func (l handingLink) HearHandOvers(h HandOverFunc) { l.given <- h } // **The link is given the queue's hand-over** when it is served: what the controller asks reaches the engine's @@ -213,6 +228,30 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { } stop() <-served + // ADR 0618 §4: an engine that holds the seat says manifest-reads-changed once for each link it serves, "started" on + // the first and "returned" on every one after it, and never once per pass. + said := make(chan delivery.ManifestReadsChanged, 4) + for _, why := range []string{delivery.ReadsStarted, delivery.ReadsReturned} { + el := readsLink{quietLink: newQuietLink(), said: said} + eq := q + eq.Acts = &HandActs{Node: "n1", State: &MemState{}} + ectx, estop := context.WithCancel(context.Background()) + edone := make(chan error, 1) + go func() { edone <- serve(ectx, el, m, eq, nil, time.Second) }() + select { + case got := <-said: + if got.Why != why || got.Node != "n1" { + t.Errorf("the link said %+v, want %s", got, why) + } + case <-time.After(5 * time.Second): + t.Fatalf("the link %s said nothing", why) + } + estop() + <-edone + } + if len(said) != 0 { + t.Errorf("more than once per link: %d more said", len(said)) + } // 774 (ADR 0563 §1): a call of the node seat reaches its verb with its caller as the subject proves it, unproven on // the subject that names none; a verb the seat does not serve is refused, and the answer is a tool's envelope. t.Run("a call of the node seat reaches its verb with its proven caller", func(t *testing.T) { @@ -227,6 +266,10 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { PullWaits: func() (any, error) { return map[string]any{"waits": []map[string]any{{"module": "web", "holder": "distribution", "last-retry": "distribution settled"}}}, nil }, + // ADR 0618 §3: what this engine states it reads, and when it last stated it. + ManifestReads: func() ([]string, time.Time) { + return []string{"form:a", "seat:b"}, time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC) + }, // ADR 0565 §6, §15: the node's key of the engine's state packages. Packages: func(write bool) (any, error) { key := []byte(`{"node":"n1","packages":[{"package":"curl","modules":["backup","web"],"absent":false}]}`) @@ -255,6 +298,7 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { {"packages", EngineToolSubject("packages", "n1"), `"source":"mesh-host.packages","key":{"node":"n1"`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`}, + {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"v":1,"node":"n1","manifest-reads":["form:a","seat:b"],"stated-at":"2026-11-04T10:00:00Z"`}, {"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`}, } { subject, body, ok := strings.Cut(row.subject, " ") diff --git a/internal/link/record.go b/internal/link/record.go index 740184f..fc19e2a 100644 --- a/internal/link/record.go +++ b/internal/link/record.go @@ -5,6 +5,7 @@ import ( "slices" "sort" "sync" + "time" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -37,6 +38,9 @@ type RecordKeys struct { // once: it is the build's, and never changes while it runs. var manifestReads = sync.OnceValue(catalogue.ManifestReads) +// readsStatedAt is when this engine computed what it reads: its start (the verb manifest-reads, ADR 0618 §3). +var readsStatedAt = time.Now().UTC() + // recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that // says nothing of the machine, such as a rekey), so the record last written stands. func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { diff --git a/internal/link/run.go b/internal/link/run.go index 2e8b08d..545fc7b 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -218,6 +218,8 @@ func serve(ctx context.Context, link Link, m Membership, queue *Session, say Ann if queue.Acts != nil { if s, ok := link.(EngineSeats); ok { s.ServeEngineSeat(queue.Acts.Call) + // And what it reads of a manifest, said once for this link (novox/hq ADR 0618 §4). + queue.SayReads() } if a, ok := link.(OperatorAsks); ok { if err := a.HearWarrants(meshasks.EngineAsker(m.Node), queue.Acts.Heard); err != nil { diff --git a/internal/link/session.go b/internal/link/session.go index 7dd16af..4bb37f6 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -87,6 +87,12 @@ type Session struct { // saying is held while a facts event is noted, so the haves are written in the order the facts are made. saying sync.Mutex + // reads says whether the engine has said its manifest reads on a link before (novox/hq ADR 0618 §4). + reads struct { + sync.Mutex + linked bool + } + // haves is what this node-engine writes beside its facts (novox/hq ADR 0429 §4, issue 1132). haves haveState // healthWritten is this node's health report as last written or read (WriteNodeHealth): the health loop alone @@ -383,6 +389,32 @@ func (q *Session) RouterRecord(key string) (*stdio.StateEntry, error) { return asking.RouterRecord(key) } +// StatedReads is what this engine states it reads of a manifest and when it computed them, which is its start (the +// node seat's verb manifest-reads, novox/hq ADR 0618 §3): the record is written only when it differs, so its put says +// nothing of when an unchanged statement was made. +func (q *Session) StatedReads() ([]string, time.Time) { return manifestReads(), readsStatedAt } + +// SayReads says the node seat's event manifest-reads-changed once for the link just opened (novox/hq ADR 0618 §4): +// why "started" on the engine's first link and "returned" on every link after it, so a reader asks the verb again once +// per connection and never once per pass. +func (q *Session) SayReads() { + q.reads.Lock() + why := delivery.ReadsStarted + if q.reads.linked { + why = delivery.ReadsReturned + } + q.reads.linked = true + q.reads.Unlock() + raw, err := delivery.EncodeManifestReadsChanged(delivery.ManifestReadsChanged{Node: q.Membership.Node, + ManifestReads: manifestReads(), At: time.Now().UTC(), Why: why}) + if err != nil { + q.init() + q.Say("cannot say the node seat's manifest-reads-changed: " + err.Error()) + return + } + q.SayEngine("manifest-reads-changed", json.RawMessage(raw)) +} + // SayEngine says one of the node seat's events over the link open now; with none open it is not said. func (q *Session) SayEngine(event string, body any) { q.init() -- 2.54.0 From 0128b962ab7805da0caf04f13f9a07575d6609a5 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:33:50 +0100 Subject: [PATCH 10/73] =?UTF-8?q?The=20node-engine=20speaks=20its=20seat's?= =?UTF-8?q?=20generated=20contract:=20manifest-reads=20and=20manifest-read?= =?UTF-8?q?s-changed=20in=20mesh-contracts'=20types=20(ADR=200618=20=C2=A7?= =?UTF-8?q?3,=20=C2=A74,=20ADR=200613=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verb's answer and the event's body are mesh-contracts' nodeengine types, not mesh-sdk's, and the seat's verb and event names are the contract's. The two reasons of the event, started and returned, are the engine's own words beside it. Requires mesh-contracts and the registry milestone's mesh-sdk (a go.work, uncommitted, until the group pins them). Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- internal/link/handacts.go | 34 ++++++++++++++++++---------------- internal/link/handover_test.go | 19 +++++++++++-------- internal/link/session.go | 24 +++++++++++++----------- 3 files changed, 42 insertions(+), 35 deletions(-) diff --git a/internal/link/handacts.go b/internal/link/handacts.go index 2e70cd2..01c26b0 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/asks" @@ -33,33 +34,33 @@ import ( // time and at most one started in an hour. Every act, with who asked as the runtime proved it, is kept in the engine's own // declared state acts (ActsBucket, created by the grants part), under its own node's keys alone, read by the verb acts. -// The acts of the seat, as its verbs name them. +// The acts of the seat, as its verbs name them: the seat's contract's names (mesh-contracts, seats/node-engine). const ( - ActHandOver = "hand-over" - ActAccess = "access-act" - ActJudgeDirectories = "judge-directories" - ActSetuidSearch = "setuid-search" + ActHandOver = nodeengine.VerbHandOver + ActAccess = nodeengine.VerbAccessAct + ActJudgeDirectories = nodeengine.VerbJudgeDirectories + ActSetuidSearch = nodeengine.VerbSetuidSearch // ActComposition reads a module's composition beside what runs of it; read-only, no warrant (issue 774). - ActComposition = "composition" + ActComposition = nodeengine.VerbComposition // ActCompose composes the node now, the recovery of a failed composition (issue 774). - ActCompose = "compose" + ActCompose = nodeengine.VerbCompose // ActPackages reads the packages this node's compositions declare, and with write writes its key again (novox/hq // ADR 0565 §6, §15). - ActPackages = "packages" + ActPackages = nodeengine.VerbPackages // ActComposeCandidate composes a check's candidate on this node in shadow, read-only (novox/hq ADR 0593 §7). - ActComposeCandidate = "compose-candidate" + ActComposeCandidate = nodeengine.VerbComposeCandidate // ActPullWaits reads the failed applies that wait on a store's holder, and the last retry with its trigger (design 51, // issue 1966 ENG-9). - ActPullWaits = "pull-waits" + ActPullWaits = nodeengine.VerbPullWaits // ActManifestReads reads the reader capabilities this engine states (novox/hq ADR 0618 §3). - ActManifestReads = "manifest-reads" + ActManifestReads = nodeengine.VerbManifestReads ) -// The states of an act. +// The states of an act: asked, done and refused are also the seat's events. const ( - HandAsked = "asked" - HandDone = "done" - HandRefused = "refused" + HandAsked = nodeengine.EventAsked + HandDone = nodeengine.EventDone + HandRefused = nodeengine.EventRefused HandAnswered = "answered" ) @@ -251,7 +252,8 @@ func (h *HandActs) Call(c EngineCall) (any, error) { return nil, errors.New("this engine states no manifest reads yet: its node agent has not started") } reads, at := h.ManifestReads() - return delivery.ManifestReadsAnswer{V: delivery.Version, Node: h.Node, ManifestReads: reads, StatedAt: at}, nil + return nodeengine.ManifestReadsAnswer{V: delivery.Version, Node: h.Node, ManifestReads: reads, + StatedAt: at.UTC().Format(time.RFC3339Nano)}, nil case "acts": if id := arg("id"); id != "" { return h.Act(id) diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index c884172..3970f2d 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/micro" ) @@ -195,15 +195,18 @@ type handingLink struct { // readsLink is a quiet link that serves the node seat and keeps the manifest-reads-changed events said on it. type readsLink struct { *quietLink - said chan delivery.ManifestReadsChanged + said chan nodeengine.ManifestReadsChangedEvent } func (l readsLink) ServeEngineSeat(EngineFunc) {} func (l readsLink) SayEngine(event string, body any) error { - if e, err := delivery.DecodeManifestReadsChanged(body.(json.RawMessage)); event == "manifest-reads-changed" && err == nil { + // As the bus carries it: the body written and read back as the seat's contract. + raw, err := json.Marshal(body) + var e nodeengine.ManifestReadsChangedEvent + if err == nil && event == nodeengine.EventManifestReadsChanged && json.Unmarshal(raw, &e) == nil { l.said <- e } - return nil + return err } func (l handingLink) HearHandOvers(h HandOverFunc) { l.given <- h } @@ -230,8 +233,8 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { <-served // ADR 0618 §4: an engine that holds the seat says manifest-reads-changed once for each link it serves, "started" on // the first and "returned" on every one after it, and never once per pass. - said := make(chan delivery.ManifestReadsChanged, 4) - for _, why := range []string{delivery.ReadsStarted, delivery.ReadsReturned} { + said := make(chan nodeengine.ManifestReadsChangedEvent, 4) + for _, why := range []string{ReadsStarted, ReadsReturned} { el := readsLink{quietLink: newQuietLink(), said: said} eq := q eq.Acts = &HandActs{Node: "n1", State: &MemState{}} @@ -240,7 +243,7 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { go func() { edone <- serve(ectx, el, m, eq, nil, time.Second) }() select { case got := <-said: - if got.Why != why || got.Node != "n1" { + if at, err := time.Parse(time.RFC3339Nano, got.At); got.Why != why || got.Node != "n1" || got.V != 1 || err != nil || at.IsZero() { t.Errorf("the link said %+v, want %s", got, why) } case <-time.After(5 * time.Second): @@ -298,7 +301,7 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { {"packages", EngineToolSubject("packages", "n1"), `"source":"mesh-host.packages","key":{"node":"n1"`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`}, - {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"v":1,"node":"n1","manifest-reads":["form:a","seat:b"],"stated-at":"2026-11-04T10:00:00Z"`}, + {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`}, {"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`}, } { subject, body, ok := strings.Cut(row.subject, " ") diff --git a/internal/link/session.go b/internal/link/session.go index 4bb37f6..ed8bb75 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -5,12 +5,13 @@ import ( "encoding/json" "errors" "fmt" - "git.novox.be/novox/mesh-sdk/go/delivery" "strings" "sync" "time" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -394,25 +395,26 @@ func (q *Session) RouterRecord(key string) (*stdio.StateEntry, error) { // nothing of when an unchanged statement was made. func (q *Session) StatedReads() ([]string, time.Time) { return manifestReads(), readsStatedAt } +// The reasons the engine says its reads, in the event's why (novox/hq ADR 0618 §4): it started, or its node returned +// after being away (a link after the first). +const ( + ReadsStarted = "started" + ReadsReturned = "returned" +) + // SayReads says the node seat's event manifest-reads-changed once for the link just opened (novox/hq ADR 0618 §4): // why "started" on the engine's first link and "returned" on every link after it, so a reader asks the verb again once // per connection and never once per pass. func (q *Session) SayReads() { q.reads.Lock() - why := delivery.ReadsStarted + why := ReadsStarted if q.reads.linked { - why = delivery.ReadsReturned + why = ReadsReturned } q.reads.linked = true q.reads.Unlock() - raw, err := delivery.EncodeManifestReadsChanged(delivery.ManifestReadsChanged{Node: q.Membership.Node, - ManifestReads: manifestReads(), At: time.Now().UTC(), Why: why}) - if err != nil { - q.init() - q.Say("cannot say the node seat's manifest-reads-changed: " + err.Error()) - return - } - q.SayEngine("manifest-reads-changed", json.RawMessage(raw)) + q.SayEngine(nodeengine.EventManifestReadsChanged, nodeengine.ManifestReadsChangedEvent{V: delivery.Version, + Node: q.Membership.Node, ManifestReads: manifestReads(), At: time.Now().UTC().Format(time.RFC3339Nano), Why: why}) } // SayEngine says one of the node seat's events over the link open now; with none open it is not said. -- 2.54.0 From c6ae0fc405fb5074d1b3f9ace048fb91c562c0d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:40:34 +0100 Subject: [PATCH 11/73] The node-engine answers compose-candidate in its seat's contract: the input as a JSON object, a module's fingerprint Fp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-sdk's ComposeCandidateAnswer and ModuleComposed are now the generated nodeengine types (ADR 0613 §3, §5), so the input sent for the checker's sandbox is decoded once into the object the contract carries, its numbers kept as written (json.Number), and its size still counted from the bytes it was marshalled to. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- cmd/mesh-host/nodeagent_integration_test.go | 2 +- internal/selfcompose/candidate.go | 16 ++++++++++++---- internal/selfcompose/candidate_test.go | 5 +++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index cecee45..f6f15d3 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -603,7 +603,7 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) { t.Fatalf("the answer %s: %v", raw, err) } if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" || - a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() { + a.Modules[0].Fp != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() { t.Errorf("the node's answer to a candidate changing web: %+v", a) } after, _ := b.composed.Status() diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index 1176808..f38037b 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -1,6 +1,7 @@ package selfcompose import ( + "bytes" "context" "encoding/json" "errors" @@ -181,6 +182,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery if err != nil { return unready(err.Error()) } + sentSize := 0 laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope, fps map[string]string) { if !ask.Input { return @@ -196,13 +198,19 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery in.ComposedHow.HeldBefore = maps.Clone(baseHeld) // As the sandbox reads it (composer.SentInput, the one shape): this node's own modules as taken in from their // builds, every other module's manifest left to the cases. + // The seat's contract carries it as a JSON object; its numbers are kept as written (json.Number). if body, err := json.Marshal(composer.SentInput(in, laid.env.Node)); err == nil { - a.Input = body + d := json.NewDecoder(bytes.NewReader(body)) + d.UseNumber() + var obj map[string]any + if d.Decode(&obj) == nil { + a.Input, sentSize = obj, len(body) + } } }) - if len(a.Input) > maxSentInput { + if sentSize > maxSentInput { // Past what the bus carries in one answer, the checker would hear nothing: refused, saying so. - size := len(a.Input) + size := sentSize a.Input = nil return refuse(fmt.Sprintf("the composition input this node composed from is %d bytes, more than the %d an answer on the bus carries", size, maxSentInput)) } @@ -251,7 +259,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery m.State, m.Words = delivery.ModuleRefusedState, err.Error() return m, true } - m.State, m.FP, m.Digest = delivery.ModuleComposedState, comp.FP, dig + m.State, m.Fp, m.Digest = delivery.ModuleComposedState, comp.FP, dig if before, ok := was[module]; !ok { m.Changed = true } else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig { diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index ea78e81..e3cc8ae 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -103,7 +103,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { t.Fatalf("the answer does not fit the seat's shape: %v (%+v)", err, a) } if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" || - a.Modules[0].State != delivery.ModuleComposedState || a.Modules[0].FP != fpNew || !a.Modules[0].Changed || + a.Modules[0].State != delivery.ModuleComposedState || a.Modules[0].Fp != fpNew || !a.Modules[0].Changed || a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.Milliseconds() { t.Errorf("an accepted candidate answered %+v", a) } @@ -111,7 +111,8 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { // through the same path. sent := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted, Input: true}) var in compose.Input - if err := json.Unmarshal(sent.Input, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" || + raw, _ := json.Marshal(sent.Input) + if err := json.Unmarshal(raw, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" || in.ComposedHow.Builds["lemurs"] != fpNew { t.Errorf("the input sent for the sandbox does not say how the node composes: %+v, %v", in.ComposedHow, err) } -- 2.54.0 From 2b14f166de5b86ebd27847ffd5440e6f3d196619 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:02:44 +0100 Subject: [PATCH 12/73] The node-engine speaks operator-channel, mesh-vault and node-engine through their contracts and clients; it serves no pull-waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0627 §1, §4: mesh-sdk keeps no asks, asker, vault answers or compose-candidate checks, so the node-engine takes them from mesh-contracts: operator-channel's contract and client (asks, warrants, the asker's client), mesh-vault's sign verb and answer, and node-engine's own contract and client for compose-candidate's ask, answer, verdicts and bound. The lead's ruling on pull-waits (no record gives the seat that verb): the verb and its handler go; the row that read the waits now holds the seat refusing the verb, and failed before the change. The go.mod requires of mesh-contracts' modules come with the group's pin. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- cmd/mesh-host/authority_test.go | 5 +- cmd/mesh-host/engineseat.go | 37 +++++-------- cmd/mesh-host/libraries_test.go | 12 ++--- cmd/mesh-host/main.go | 1 - cmd/mesh-host/nodeagent_integration_test.go | 7 +-- cmd/mesh-host/trust.go | 5 +- internal/composer/composer.go | 2 +- internal/link/access_act_test.go | 34 ++++++------ internal/link/engineseat.go | 12 ++--- internal/link/handacts.go | 58 +++++++++------------ internal/link/handover_test.go | 7 +-- internal/link/run.go | 4 +- internal/selfcompose/candidate.go | 54 ++++++++++--------- internal/selfcompose/candidate_test.go | 38 +++++++------- 14 files changed, 129 insertions(+), 147 deletions(-) diff --git a/cmd/mesh-host/authority_test.go b/cmd/mesh-host/authority_test.go index 21b4f20..0ff93a1 100644 --- a/cmd/mesh-host/authority_test.go +++ b/cmd/mesh-host/authority_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-contracts/go/seats/meshvault" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" @@ -108,13 +109,13 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { answer := signedBy(vaultCA, vault) ask := func(_ context.Context, verb string, args any) (json.RawMessage, error) { asked = append(asked, verb, args) - return json.Marshal(delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: "n1", Certificate: answer}) + return json.Marshal(meshvault.SignAnswer{Kind: delivery.KindNodeCertificate, Node: "n1", Certificate: answer}) } written := 0 if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err != nil { t.Fatal(err) } - if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != delivery.SignVerb { + if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != meshvault.VerbSign { t.Errorf("the vault's certificate: kept %v, written %d, asked %v", got.Certificate == answer, written, asked) } answer = signedBy(testAuthority(t, controllerPub, controller), controller) diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index 307290d..9d1f309 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -13,10 +13,10 @@ import ( "syscall" "time" + nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" stdio "git.novox.be/novox/mesh-sdk/go" - meshasker "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" @@ -256,8 +256,8 @@ func engineActs(opts options, node string, queue *link.Session, search *accounts return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why) }, ManifestReads: queue.StatedReads, Say: queue.SayEngine} - asker := asks.EngineAsker(node) - h.Asker = &meshasker.Client{Module: asker, State: state, Publish: queue.PublishAsk, + asker := occ.EngineAsker(node) + h.Asker = &occ.Client{Module: asker, State: state, Publish: queue.PublishAsk, Record: func(id string) (*stdio.StateEntry, error) { return queue.RouterRecord(asker + "." + id) }} return h } @@ -284,17 +284,6 @@ const compositionReadFor = 20 * time.Second // compositionFrom answers the verb composition from the node agent that runs now (issue 774); refused in words while // none does. -// pullWaitsFrom answers the verb pull-waits from the agent running now (ENG-9). -func pullWaitsFrom(current *agentNow) func() (any, error) { - return func() (any, error) { - ag := current.get() - if ag == nil { - return nil, errors.New("this engine's node agent has not started, so it waits on no store yet") - } - return map[string]any{"waits": ag.PullWaits()}, nil - } -} - func compositionFrom(current *agentNow) func(module, digest string, diff bool) (any, error) { return func(module, digest string, diff bool) (any, error) { ag := current.get() @@ -375,26 +364,26 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s // composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this // node in shadow by the run's shadow, applying and writing nothing, within the shadow's own bound (shorter than -// delivery.ComposeAnswerBound, selfcompose candidateWithin). The answer is -// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet +// nec.AnswerBound, selfcompose candidateWithin). The answer is +// the seat's shape (nodeengine.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet // answers unready, which the checker takes as no answer. func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) { return func(candidate string, input bool) (any, error) { - ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input} - if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil { + ask := nodeengine.ComposeCandidateInput{Candidate: candidate, Input: input} + if _, err := nec.EncodeAsk(ask); err != nil { return nil, err } - var a delivery.ComposeCandidateAnswer + var a nodeengine.ComposeCandidateAnswer if s := shadow.Load(); s == nil { // Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9). - a = delivery.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: delivery.ComposeCandidateUnready, + a = nodeengine.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: nec.Unready, Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"} } else { - ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound) + ctx, cancel := context.WithTimeout(context.Background(), nec.AnswerBound) defer cancel() a = s.ComposeCandidate(ctx, ask) } - raw, err := delivery.EncodeComposeCandidateAnswer(a) + raw, err := nec.EncodeAnswer(a) if err != nil { return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err) } diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go index 10910c4..da6902a 100644 --- a/cmd/mesh-host/libraries_test.go +++ b/cmd/mesh-host/libraries_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - meshasker "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" + occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" @@ -58,16 +58,16 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) { // 774 (ADR 0563 §4): the library's home engine asks its take of the operator itself, once: no second ask while one is // open, nor after it ended; and a library waiting for its disk is not asked. t.Run("the home engine asks the take once", func(t *testing.T) { - var asked []asks.Ask + var asked []oc.Ask state := &link.MemState{} h := &link.HandActs{Node: "n1", State: state, JudgeAccess: func(act, access string) (link.Judged, error) { return link.Judged{Subject: access, Path: root, Sets: "the group mesh-media", Disk: "0:0 755"}, nil }, AccessAct: func(link.AccessAct) (string, error) { return "taken", nil }} - h.Asker = &meshasker.Client{Module: asks.EngineAsker("n1"), State: state, + h.Asker = &occ.Client{Module: occ.EngineAsker("n1"), State: state, Publish: func(subject string, body any, id string) error { - if a, ok := body.(asks.Ask); ok { + if a, ok := body.(oc.Ask); ok { asked = append(asked, a) } return nil @@ -78,7 +78,7 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) { if len(waiting) != 1 || waiting[0] != "library:media" || len(asked) != 1 { t.Fatalf("waiting %v asked %d times", waiting, len(asked)) } - h.Heard(asks.Warrant{Ask: asked[0].ID, Asker: asks.EngineAsker("n1"), Outcome: asks.OutcomeExpired, At: time.Now()}) + h.Heard(oc.Warrant{Ask: asked[0].ID, Asker: occ.EngineAsker("n1"), Outcome: occ.OutcomeExpired, At: occ.FormatTime(time.Now())}) askTakes(h, waiting, nil) if len(asked) != 1 { t.Fatalf("the take was asked again after its ask ended: %d", len(asked)) diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index b6626fb..0d03f6b 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1015,7 +1015,6 @@ func runLink(ctx context.Context, opts options) error { go reconcileActs(ctx, queue.Acts) current := &agentNow{} queue.Acts.Composition = compositionFrom(current) - queue.Acts.PullWaits = pullWaitsFrom(current) queue.Acts.Compose = composeFrom(current, &composerNow) queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow) queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index f6f15d3..4ae58f7 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -598,12 +599,12 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) { t.Fatal(err) } raw, _ := got.(json.RawMessage) - a, err := delivery.DecodeComposeCandidateAnswer(raw) + a, err := nec.DecodeAnswer(raw) if err != nil { t.Fatalf("the answer %s: %v", raw, err) } - if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" || - a.Modules[0].Fp != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() { + if a.Verdict != nec.Composed || len(a.Modules) != 1 || a.Modules[0].Module != "web" || + a.Modules[0].Fp != fpIt("c") || !a.Modules[0].Changed || a.Took > nec.AnswerBound.Milliseconds() { t.Errorf("the node's answer to a candidate changing web: %+v", a) } after, _ := b.composed.Status() diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 29f2702..3d3b963 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/go/seats/meshvault" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" @@ -259,11 +260,11 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust, if err != nil || len(key) != ed25519.PublicKeySize { return fmt.Errorf("this node's serving key is not an ed25519 key") } - raw, err := ask(ctx, delivery.SignVerb, map[string]any{"kind": delivery.KindNodeCertificate, "node": node}) + raw, err := ask(ctx, meshvault.VerbSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node}) if err != nil { return err } - var a delivery.SignAnswer + var a meshvault.SignAnswer if err := json.Unmarshal(raw, &a); err != nil { return fmt.Errorf("the vault's answer is not a signing: %w", err) } diff --git a/internal/composer/composer.go b/internal/composer/composer.go index b5a8898..81fb035 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -306,7 +306,7 @@ const Package = "github.com/novox/mesh-host/cmd/mesh-compose" // Decode reads a request (sdk.Decode); it refuses one that is not JSON or of another version. func Decode(data []byte) (Request, error) { return sdk.Decode(data) } -// SentInput is the composition input a node-engine answers for the sandbox (delivery.ComposeCandidateAsk.Input), from the +// SentInput is the composition input a node-engine answers for the sandbox (nodeengine.ComposeCandidateInput.Input), from the // input it composed node from: the one shape both read (issue 1126, checks of mesh-host #200). Every other node's modules // are left to the cases (the indexer's manifests, as the bus reader reads them too); node's own placed modules stay as the // bus reader took them in from their builds (their artifacts, the texts of their templates archive, what is handed to the diff --git a/internal/link/access_act_test.go b/internal/link/access_act_test.go index 82bf596..17278bb 100644 --- a/internal/link/access_act_test.go +++ b/internal/link/access_act_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" + occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" ) // The take act's ask (novox/hq ADR 0325 §8): a hand-over's fields and the act's own in one signed object, under a @@ -78,7 +78,7 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { t.Run("the node seat's hand-over acts only on the operator's warrant", func(t *testing.T) { for _, row := range []struct { name string - warrant func(asked asks.Ask, w asks.Warrant) asks.Warrant + warrant func(asked oc.Ask, w oc.Warrant) oc.Warrant diskAfter string twice bool done int @@ -87,19 +87,19 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { {name: "no warrant", done: 0, state: HandAsked}, {name: "the operator's yes", warrant: yes, done: 1, state: HandDone}, {name: "the yes heard twice", warrant: yes, twice: true, done: 1, state: HandDone}, - {name: "a warrant for another digest", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { + {name: "a warrant for another digest", warrant: func(a oc.Ask, w oc.Warrant) oc.Warrant { w = yes(a, w) w.AskDigest = "sha256:another" return w }, done: 0, state: HandAsked}, - {name: "a no", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { + {name: "a no", warrant: func(a oc.Ask, w oc.Warrant) oc.Warrant { w = yes(a, w) - w.Option, w.Label, w.Level = "no", "No, leave it", asks.Acknowledge + w.Option, w.Label, w.Level = "no", "No, leave it", occ.Acknowledge return w }, done: 0, state: HandRefused}, {name: "the disk changed", warrant: yes, diskAfter: "0:0 700 9:2", done: 0, state: HandRefused}, - {name: "expired", warrant: func(a asks.Ask, w asks.Warrant) asks.Warrant { - return asks.Warrant{Ask: a.ID, Asker: w.Asker, Outcome: asks.OutcomeExpired, At: w.At} + {name: "expired", warrant: func(a oc.Ask, w oc.Warrant) oc.Warrant { + return oc.Warrant{Ask: a.ID, Asker: w.Asker, Outcome: occ.OutcomeExpired, At: w.At} }, done: 0, state: HandRefused}, } { t.Run(row.name, func(t *testing.T) { @@ -130,7 +130,7 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { disk = row.diskAfter } if row.warrant != nil { - w := row.warrant((*asked)[0], asks.Warrant{Asker: asks.EngineAsker("n1"), At: time.Now()}) + w := row.warrant((*asked)[0], oc.Warrant{Asker: occ.EngineAsker("n1"), At: occ.FormatTime(time.Now())}) h.Heard(w) if row.twice { h.Heard(w) @@ -153,25 +153,25 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { } // yes is the operator's yes to an ask, as the router writes it. -func yes(a asks.Ask, w asks.Warrant) asks.Warrant { - w.Ask, w.Outcome, w.Option, w.Label, w.Level = a.ID, asks.OutcomeChosen, "yes", "Yes", asks.Approve - w.By = &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"} - w.AskDigest, w.Proofs = a.Digest(), []string{"P1"} +func yes(a oc.Ask, w oc.Warrant) oc.Warrant { + w.Ask, w.Outcome, w.Option, w.Label, w.Level = a.ID, occ.OutcomeChosen, "yes", "Yes", occ.Approve + w.By = &oc.Person{Who: occ.Operator, Kind: "telegram", Verified: "user id verified"} + w.AskDigest, w.Proofs = occ.Digest(a), []string{"P1"} return w } // anEngine is a node seat with its own state in a temporary folder, a router that keeps what it was asked, and a // hand-over that counts what it performed. -func anEngine(t *testing.T) (*HandActs, *[]asks.Ask, *int) { +func anEngine(t *testing.T) (*HandActs, *[]oc.Ask, *int) { t.Helper() - var asked []asks.Ask + var asked []oc.Ask done := 0 state := &MemState{} h := &HandActs{Node: "n1", State: state, HandOver: func(path, by string) (string, error) { done++; return path + " handed over by " + by, nil }} - h.Asker = &asker.Client{Module: asks.EngineAsker("n1"), State: state, + h.Asker = &occ.Client{Module: occ.EngineAsker("n1"), State: state, Publish: func(subject string, body any, id string) error { - if a, ok := body.(asks.Ask); ok && subject == asks.AskSubject(asks.EngineAsker("n1")) { + if a, ok := body.(oc.Ask); ok && subject == oc.AcceptSubjectAsk(occ.EngineAsker("n1")) { asked = append(asked, a) } return nil diff --git a/internal/link/engineseat.go b/internal/link/engineseat.go index c51d0e9..5256348 100644 --- a/internal/link/engineseat.go +++ b/internal/link/engineseat.go @@ -9,8 +9,8 @@ import ( "strings" "time" + oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/micro" @@ -192,7 +192,7 @@ func (l *natsLink) SayEngine(event string, body any) error { return err } -// The engine asks the operator under its own asker name (asks.EngineAsker) on the router's seat (ADR 0259 §3, as ADR +// The engine asks the operator under its own asker name (occ.EngineAsker) on the router's seat (ADR 0259 §3, as ADR // 0563 §3 changes it): its ask and cancel on the seat's work stream, its warrants heard on its own decided subject, and // its asks' state read from the router's records under its own name. @@ -204,7 +204,7 @@ const RouterRecords = "messenger_asks" type OperatorAsks interface { PublishAsk(subject string, body any, id string) error RouterRecord(key string) (*stdio.StateEntry, error) - HearWarrants(asker string, heard func(asks.Warrant)) error + HearWarrants(asker string, heard func(oc.Warrant)) error } // PublishAsk publishes an ask or a cancel on the router's seat, once per id within the stream's duplicate window. @@ -236,9 +236,9 @@ func (l *natsLink) RouterRecord(key string) (*stdio.StateEntry, error) { } // HearWarrants hears the warrants for asker on its decided subject, each handed to heard as it arrives. -func (l *natsLink) HearWarrants(asker string, heard func(asks.Warrant)) error { - sub, err := l.conn.Subscribe(asks.DecidedSubject(asker), func(msg *nats.Msg) { - var w asks.Warrant +func (l *natsLink) HearWarrants(asker string, heard func(oc.Warrant)) error { + sub, err := l.conn.Subscribe(oc.SubjectDecided(asker), func(msg *nats.Msg) { + var w oc.Warrant if json.Unmarshal(msg.Data, &w) == nil { heard(w) } diff --git a/internal/link/handacts.go b/internal/link/handacts.go index 01c26b0..a192515 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -13,10 +13,10 @@ import ( "sync" "time" + occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asker" - "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -24,9 +24,9 @@ import ( // // A hand-over and an access act are the engine's own asks, performed only on the warrant (§3): a call performs nothing. // The engine judges every value as it does for the controller's signed ask, then refuses in words or asks the operator -// under its own asker name (asks.EngineAsker) at the level approve, binding the digest of the verb, the node, every +// under its own asker name (occ.EngineAsker) at the level approve, binding the digest of the verb, the node, every // argument and the owner, group and mode it will set. A warrant is claimed once by compare-and-set in the engine's own -// state (asker.Client.Take), held to the ask it answers, and to the act bound and the disk as judged, judged again now; +// state (occ.Client.Take), held to the ask it answers, and to the act bound and the disk as judged, judged again now; // only then is the act performed and recorded with the warrant. A decline, an expiry or a changed disk says refused and // changes nothing. One ask is open per path or access. // @@ -49,9 +49,6 @@ const ( ActPackages = nodeengine.VerbPackages // ActComposeCandidate composes a check's candidate on this node in shadow, read-only (novox/hq ADR 0593 §7). ActComposeCandidate = nodeengine.VerbComposeCandidate - // ActPullWaits reads the failed applies that wait on a store's holder, and the last retry with its trigger (design 51, - // issue 1966 ENG-9). - ActPullWaits = nodeengine.VerbPullWaits // ActManifestReads reads the reader capabilities this engine states (novox/hq ADR 0618 §3). ActManifestReads = nodeengine.VerbManifestReads ) @@ -129,8 +126,8 @@ type HandActs struct { Node string // State is where the acts and the asks are kept: this node's keys of ActsBucket (BusState), each key one act. State ActStore - // Asker asks the operator as asks.EngineAsker(Node), with State as its store. - Asker *asker.Client + // Asker asks the operator as occ.EngineAsker(Node), with State as its store. + Asker *occ.Client // JudgeHandOver judges a hand-over's path, refusing in words; HandOver records it on the warrant. JudgeHandOver func(path string) (Judged, error) HandOver HandOverFunc @@ -156,8 +153,6 @@ type HandActs struct { // module, applying and writing nothing; with input, also the composition input it composed from (the verb // compose-candidate, ADR 0593 §7). ComposeCandidate func(candidate string, input bool) (any, error) - // PullWaits answers which failed applies wait on which store's holder, and the last retry of each with what triggered it. - PullWaits func() (any, error) // ManifestReads is what this engine states it reads of a manifest and when its record last carried them (the verb // manifest-reads, novox/hq ADR 0618 §3). ManifestReads func() ([]string, time.Time) @@ -242,11 +237,6 @@ func (h *HandActs) Call(c EngineCall) (any, error) { input = b } return h.ComposeCandidate(arg("candidate"), input) - case ActPullWaits: - if h.PullWaits == nil { - return nil, errors.New("this engine waits on no store yet: its node agent has not started") - } - return h.PullWaits() case ActManifestReads: if h.ManifestReads == nil { return nil, errors.New("this engine states no manifest reads yet: its node agent has not started") @@ -539,8 +529,8 @@ func (h *HandActs) id(act string) string { } // bound is the act an ask's yes binds: the verb, the node, every argument and what it will set (ADR 0563 §3). -func (h *HandActs) bound(a HandAct) asks.Act { - act := asks.Act{"verb": a.Act, "node": h.Node, "arg.subject": a.Subject, "arg.why": a.Why, "sets": a.Sets} +func (h *HandActs) bound(a HandAct) occ.Act { + act := occ.Act{"verb": a.Act, "node": h.Node, "arg.subject": a.Subject, "arg.why": a.Why, "sets": a.Sets} if a.Path != "" { act["path"] = a.Path } @@ -601,7 +591,7 @@ func (h *HandActs) ask(act, caller, why string, auto bool, j Judged) (any, error defer h.mu.Unlock() now := h.now().UTC() a := HandAct{ID: h.id(act), Act: act, Subject: j.Subject, Path: j.Path, Caller: caller, Why: why, At: now, - State: HandAsked, Sets: j.Sets, Disk: j.Disk, Expires: now.Add(asks.ApproveLasts - time.Minute), Auto: auto} + State: HandAsked, Sets: j.Sets, Disk: j.Disk, Expires: now.Add(occ.ApproveLasts - time.Minute), Auto: auto} a.Ask = a.ID // One ask open per path or access: its open key made by a create, which a second ask cannot make. if _, err := h.State.Create(openKey(act, j.Subject), map[string]string{"ask": a.Ask}); errors.Is(err, stdio.ErrChanged) { @@ -622,7 +612,7 @@ func (h *HandActs) ask(act, caller, why string, auto bool, j Judged) (any, error _ = h.State.Delete(openKey(act, j.Subject)) return nil, err } - bind, err := asks.ActDigest(h.bound(a)) + bind, err := occ.ActDigest(h.bound(a)) if err != nil { return nil, err } @@ -633,16 +623,16 @@ func (h *HandActs) ask(act, caller, why string, auto bool, j Judged) (any, error "The library's group, group write and setgid are set on everything under its root.", "The library waits and nothing under its root changes." } - if len([]rune(headline)) > asks.HeadlineLength { - headline = string([]rune(headline)[:asks.HeadlineLength]) + if len([]rune(headline)) > occ.HeadlineLength { + headline = string([]rune(headline)[:occ.HeadlineLength]) } - asked := asks.Ask{ID: a.Ask, Headline: headline, Who: asks.Operator, + asked := oc.Ask{ID: a.Ask, Headline: headline, Who: occ.Operator, Explanation: j.Words + " Your yes sets " + j.Sets + "; nothing changes until you answer.", Whole: j.Words + " (" + j.Path + ") Your yes sets " + j.Sets + "; nothing changes until you answer.", Details: "Asked by " + caller + " on " + h.Node + orWhy(why), - Options: []asks.Option{{ID: "yes", Label: "Yes", Does: does, Level: asks.Approve, Binds: bind}, - {ID: "no", Label: "No, leave it", Does: stays, Level: asks.Acknowledge}}, - Expires: a.Expires, OnExpiry: stays, About: EngineSeat + "-" + h.Node + "-" + act} + Options: []oc.Option{{ID: "yes", Label: "Yes", Does: does, Level: occ.Approve, Binds: bind}, + {ID: "no", Label: "No, leave it", Does: stays, Level: occ.Acknowledge}}, + Expires: occ.FormatTime(a.Expires), OnExpiry: stays, About: EngineSeat + "-" + h.Node + "-" + act} if err := h.Asker.Ask(asked); err != nil { _, _, _ = h.end(a.Ask, func(x *HandAct) { x.State, x.Said = HandRefused, "the operator could not be asked: "+err.Error() }) return nil, fmt.Errorf("the operator could not be asked, and nothing changed: %w", err) @@ -663,10 +653,10 @@ func orWhy(why string) string { return ": " + why } -// Warranted is the act on the operator's answer, handed once by asker.Client.Take after it held the warrant to the +// Warranted is the act on the operator's answer, handed once by occ.Client.Take after it held the warrant to the // ask: a no is recorded refused; a yes is held to the act the option bound and the disk as judged, judged again now, // and only then performed and recorded done with the warrant. -func (h *HandActs) Warranted(o asks.Option, w asks.Warrant) error { +func (h *HandActs) Warranted(o oc.Option, w oc.Warrant) error { a, _, err := h.readOne(actKey(w.Ask)) if err != nil { return err @@ -698,7 +688,7 @@ func (h *HandActs) Warranted(o asks.Option, w asks.Warrant) error { } now := *a now.Sets, now.Path = j.Sets, j.Path - if err := o.Performs(h.bound(now)); err != nil { + if err := occ.Performs(o, h.bound(now)); err != nil { return refuse("what the yes bound is not what would be done now: " + err.Error()) } if j.Disk != a.Disk { @@ -723,7 +713,7 @@ func (h *HandActs) Warranted(o asks.Option, w asks.Warrant) error { return err } -func warrantOn(a *HandAct, w asks.Warrant) { +func warrantOn(a *HandAct, w oc.Warrant) { a.Proofs = w.Proofs if w.By != nil { a.By, a.Via = w.By.Who, w.By.Kind @@ -733,22 +723,22 @@ func warrantOn(a *HandAct, w asks.Warrant) { } } -func operatorOf(w asks.Warrant) string { +func operatorOf(w oc.Warrant) string { if w.By == nil { return "the operator" } return "the " + w.By.Who + " via " + w.By.Kind } -// Heard is a warrant heard on the engine's decided subject: taken once (asker.Client.Take), and an ask that ended with +// Heard is a warrant heard on the engine's decided subject: taken once (occ.Client.Take), and an ask that ended with // no choice recorded refused. -func (h *HandActs) Heard(w asks.Warrant) { +func (h *HandActs) Heard(w oc.Warrant) { if h.Asker == nil { return } // A warrant the ask kept here does not hold to (another digest, another option, too late) ends nothing: it is no // answer to the ask, which stays open for the operator's. - if handled, _ := h.Asker.Take(w, h.Warranted); handled == asker.Recorded { + if handled, _ := h.Asker.Take(w, h.Warranted); handled == occ.Recorded { why := "the ask ended " + string(w.Outcome) if w.Words != "" { why += ": " + w.Words diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 3970f2d..6dd537a 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -265,10 +265,6 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { return map[string]any{"module": module, "digest": digest, "diff": diff}, nil }, Compose: func(module string) (any, error) { return map[string]any{"composed": true, "retried": module}, nil }, - // ENG-9: the failed applies that wait on a store's holder. - PullWaits: func() (any, error) { - return map[string]any{"waits": []map[string]any{{"module": "web", "holder": "distribution", "last-retry": "distribution settled"}}}, nil - }, // ADR 0618 §3: what this engine states it reads, and when it last stated it. ManifestReads: func() ([]string, time.Time) { return []string{"form:a", "seat:b"}, time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC) @@ -295,7 +291,8 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { // The recovery verb compose (issue 774) makes a pass now, with a module's apply tried again when named. {"compose", EngineToolSubject("compose", "n1"), `"composed":true,"retried":""`}, {"compose", EngineToolSubject("compose", "n1") + " " + `{"module":"route-proxy"}`, `"retried":"route-proxy"`}, - {"pull-waits", EngineToolSubject("pull-waits", "n1"), `"holder":"distribution","last-retry":"distribution settled","module":"web"`}, + // ENG-9's pull waits are carried in the failed have, read through node_haves: the seat serves no verb of them. + {"pull-waits", EngineToolSubject("pull-waits", "n1"), `"error":"the node seat node-engine serves no verb \"pull-waits\""`}, // The read verb packages (ADR 0565 §6) names its source; with write, what it wrote, and a key the engine has not // written is said and not an error. {"packages", EngineToolSubject("packages", "n1"), `"source":"mesh-host.packages","key":{"node":"n1"`}, diff --git a/internal/link/run.go b/internal/link/run.go index 545fc7b..96c9bd8 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - meshasks "git.novox.be/novox/mesh-sdk/go/asks" + occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" ) // Membership is what a node needs to reach its mesh again, held by the caller. @@ -222,7 +222,7 @@ func serve(ctx context.Context, link Link, m Membership, queue *Session, say Ann queue.SayReads() } if a, ok := link.(OperatorAsks); ok { - if err := a.HearWarrants(meshasks.EngineAsker(m.Node), queue.Acts.Heard); err != nil { + if err := a.HearWarrants(occ.EngineAsker(m.Node), queue.Acts.Heard); err != nil { say("cannot hear the operator's answers to this engine's asks: " + err.Error()) } go queue.Acts.Reconcile() diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index f38037b..a2c102d 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -12,6 +12,8 @@ import ( "sync" "time" + nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" @@ -33,11 +35,11 @@ import ( var errNotReady = errors.New("this node's shadow has not read every input yet") // candidateWithin is the node's own bound on an answer to compose-candidate: shorter than the checker's -// (delivery.ComposeAnswerBound), because the bus lets the engine answer an ask only within a minute of it (the grants +// (nec.AnswerBound), because the bus lets the engine answer an ask only within a minute of it (the grants // part's allow_responses ttl, mesh-grants ResponseTTL): an answer at the checker's 60 s bound was refused by the bus // (issue 1126, 2026-11-03 01:17:04 on shanks, "Permissions Violation for Publish to _INBOX..."), so the checker heard // nothing at all. A node not done by then answers unready, naming what it waits on. -var candidateWithin = delivery.ComposeAnswerBound - 10*time.Second +var candidateWithin = nec.AnswerBound - 10*time.Second // maxSentInput is the most composition input an answer carries: the bus carries 1 MiB in one message (its max_payload), // and the answer's own fields and envelope come beside it. @@ -92,7 +94,7 @@ func (r *candidateRun) said(now time.Time) (waits string, on string) { // ComposeCandidate is the node's answer to ask, within candidateWithin (or ctx's deadline, if sooner). Each answer is // said in one line, with what it waited on and for how long (issue 1126: ace, g14 and shanks answered nothing for a // minute while nothing said why). An answer not composed within the bound is unready, naming what the node waits on. -func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandidateAsk) delivery.ComposeCandidateAnswer { +func (s *Shadow) ComposeCandidate(ctx context.Context, ask nodeengine.ComposeCandidateInput) nodeengine.ComposeCandidateAnswer { s.mu.Lock() c := s.c s.mu.Unlock() @@ -100,14 +102,14 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi ctx, cancel := context.WithTimeout(ctx, candidateWithin) defer cancel() run := &candidateRun{} - done := make(chan delivery.ComposeCandidateAnswer, 1) + done := make(chan nodeengine.ComposeCandidateAnswer, 1) go func() { done <- s.composeCandidate(ctx, c, ask, began, run) }() - var a delivery.ComposeCandidateAnswer + var a nodeengine.ComposeCandidateAnswer select { case a = <-done: case <-ctx.Done(): _, on := run.said(c.now()) - a = delivery.ComposeCandidateAnswer{Node: c.env.Node, Candidate: ask.Candidate, Verdict: delivery.ComposeCandidateUnready, + a = nodeengine.ComposeCandidateAnswer{Node: c.env.Node, Candidate: ask.Candidate, Verdict: nec.Unready, Refused: fmt.Sprintf("the node did not compose the candidate within %s: it waits on %s", candidateWithin, on), Took: c.now().Sub(began).Milliseconds()} } @@ -122,19 +124,19 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi } // composeCandidate composes ask on scratch copies of c, each added to run as it starts. -func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery.ComposeCandidateAsk, began time.Time, run *candidateRun) delivery.ComposeCandidateAnswer { +func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengine.ComposeCandidateInput, began time.Time, run *candidateRun) nodeengine.ComposeCandidateAnswer { node := c.env.Node - a := delivery.ComposeCandidateAnswer{Node: node, Candidate: ask.Candidate} - refuse := func(why string) delivery.ComposeCandidateAnswer { - a.Verdict, a.Refused, a.Modules, a.Own = delivery.ComposeCandidateRefused, why, nil, delivery.ModuleComposed{} + a := nodeengine.ComposeCandidateAnswer{Node: node, Candidate: ask.Candidate} + refuse := func(why string) nodeengine.ComposeCandidateAnswer { + a.Verdict, a.Refused, a.Modules, a.Own = nec.Refused, why, nil, nodeengine.Module{} a.Took = c.now().Sub(began).Milliseconds() return a } // A node that cannot compose now (its shadow not ready, a read the bus did not answer, the bound passed) answers // unready: the checker takes it as no answer (ADR 0593 §9) by the verdict, the words only saying why. - unready := func(why string) delivery.ComposeCandidateAnswer { + unready := func(why string) nodeengine.ComposeCandidateAnswer { a = refuse(why) - a.Verdict = delivery.ComposeCandidateUnready + a.Verdict = nec.Unready return a } group, heads, err := delivery.ParseCompositionKey(ask.Candidate) @@ -223,8 +225,8 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery return unready("a read on the bus did not answer: " + laidErr.Error()) case laidErr != nil && baseErr != nil: // The node composes none of its own without the candidate either: the candidate changes nothing of that. - a.Verdict, a.Modules = delivery.ComposeCandidateComposed, []delivery.ModuleComposed{} - a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState, Words: laidErr.Error()} + a.Verdict, a.Modules = nec.Composed, []nodeengine.Module{} + a.Own = nodeengine.Module{Module: delivery.HaveNodeWord, State: nec.ModuleHeld, Words: laidErr.Error()} a.Took = c.now().Sub(began).Milliseconds() return a case laidErr != nil: @@ -238,15 +240,15 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery return out } was, now := byModule(baseComps), byModule(laidComps) - one := func(module string) (delivery.ModuleComposed, bool) { - m := delivery.ModuleComposed{Module: module} + one := func(module string) (nodeengine.Module, bool) { + m := nodeengine.Module{Module: module} if module == "" { m.Module = delivery.HaveNodeWord } if why, held := laidHeld[module]; held { - m.State, m.Words = delivery.ModuleRefusedState, why + m.State, m.Words = nec.ModuleRefused, why if _, before := baseHeld[module]; before || baseErr != nil { - m.State = delivery.ModuleHeldState + m.State = nec.ModuleHeld } return m, true } @@ -256,10 +258,10 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery } dig, err := delivery.ComposedDigestOf(comp) if err != nil { - m.State, m.Words = delivery.ModuleRefusedState, err.Error() + m.State, m.Words = nec.ModuleRefused, err.Error() return m, true } - m.State, m.Fp, m.Digest = delivery.ModuleComposedState, comp.FP, dig + m.State, m.Fp, m.Digest = nec.ModuleComposed, comp.FP, dig if before, ok := was[module]; !ok { m.Changed = true } else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig { @@ -281,24 +283,24 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery } } sort.Strings(names) - a.Modules = []delivery.ModuleComposed{} + a.Modules = []nodeengine.Module{} refused := false for _, m := range names { if mc, ok := one(m); ok { a.Modules = append(a.Modules, mc) - refused = refused || mc.State == delivery.ModuleRefusedState + refused = refused || mc.State == nec.ModuleRefused } } if own, ok := one(""); ok { a.Own = own } else { - a.Own = delivery.ModuleComposed{Module: delivery.HaveNodeWord, State: delivery.ModuleHeldState, + a.Own = nodeengine.Module{Module: delivery.HaveNodeWord, State: nec.ModuleHeld, Words: "the node composes no composition of its own"} } - refused = refused || a.Own.State == delivery.ModuleRefusedState - a.Verdict = delivery.ComposeCandidateComposed + refused = refused || a.Own.State == nec.ModuleRefused + a.Verdict = nec.Composed if refused { - a.Verdict = delivery.ComposeCandidateRefused + a.Verdict = nec.Refused } a.Took = c.now().Sub(began).Milliseconds() return a diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index e3cc8ae..9b8ed93 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" @@ -76,7 +78,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { _ = s.Run(ctx) }() - if a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted}); a.Verdict != delivery.ComposeCandidateUnready || a.Refused == "" { + if a := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: accepted}); a.Verdict != nec.Unready || a.Refused == "" { t.Errorf("a shadow not ready answered %+v", a) } place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, @@ -98,46 +100,46 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { time.Sleep(5 * time.Millisecond) } - a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted}) - if _, err := delivery.EncodeComposeCandidateAnswer(a); err != nil { + a := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: accepted}) + if _, err := nec.EncodeAnswer(a); err != nil { t.Fatalf("the answer does not fit the seat's shape: %v (%+v)", err, a) } - if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" || - a.Modules[0].State != delivery.ModuleComposedState || a.Modules[0].Fp != fpNew || !a.Modules[0].Changed || - a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.Milliseconds() { + if a.Verdict != nec.Composed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" || + a.Modules[0].State != nec.ModuleComposed || a.Modules[0].Fp != fpNew || !a.Modules[0].Changed || + a.Own.Module != delivery.HaveNodeWord || a.Took > nec.AnswerBound.Milliseconds() { t.Errorf("an accepted candidate answered %+v", a) } // Issue 774: the input it sends the sandbox says how it composed the node (the builds), so the sandbox composes it // through the same path. - sent := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted, Input: true}) + sent := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: accepted, Input: true}) var in compose.Input raw, _ := json.Marshal(sent.Input) if err := json.Unmarshal(raw, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" || in.ComposedHow.Builds["lemurs"] != fpNew { t.Errorf("the input sent for the sandbox does not say how the node composes: %+v, %v", in.ComposedHow, err) } - r := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: refusedKey}) - if r.Verdict != delivery.ComposeCandidateRefused || len(r.Modules) != 1 || r.Modules[0].State != delivery.ModuleRefusedState || + r := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: refusedKey}) + if r.Verdict != nec.Refused || len(r.Modules) != 1 || r.Modules[0].State != nec.ModuleRefused || !strings.Contains(r.Modules[0].Words, "a field the judge refuses") { t.Errorf("a candidate whose manifest the indexer refused answered %+v", r) } - if _, err := delivery.EncodeComposeCandidateAnswer(r); err != nil { + if _, err := nec.EncodeAnswer(r); err != nil { t.Errorf("the refused answer does not fit the seat's shape: %v", err) } missing, _ := delivery.CompositionKey("novox/x#8", "0123456789ab") - if m := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: missing}); !strings.Contains(m.Refused, "holds no candidate") { + if m := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: missing}); !strings.Contains(m.Refused, "holds no candidate") { t.Errorf("a candidate the indexer does not hold answered %+v", m) } // The group's candidate moved to other heads since the ask: the node composes not the one asked, and answers // unready (no answer), naming the heads it holds. moved, _ := delivery.CompositionKey("fix/940-no-retire-on-partial-read", "0123456789ab") - if m := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: moved}); m.Verdict != delivery.ComposeCandidateUnready || + if m := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: moved}); m.Verdict != nec.Unready || !strings.Contains(m.Refused, "heads") { t.Errorf("a candidate at other heads answered %+v", m) } ended, end := context.WithCancel(ctx) end() - if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateUnready { + if e := s.ComposeCandidate(ended, nodeengine.ComposeCandidateInput{Candidate: accepted}); e.Verdict != nec.Unready { t.Errorf("an ended bound answered %+v", e) } if len(state.written()) != 0 || applied.Load() != 0 || handed.Load() != 0 { @@ -215,11 +217,11 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { words string // in the answer's words said string // in the line said of the last answer }{ - {name: "one ask reads each manifest once", asks: 1, verdict: delivery.ComposeCandidateComposed, reads: 1, + {name: "one ask reads each manifest once", asks: 1, verdict: nec.Composed, reads: 1, said: "compose-candidate fix/1126."}, - {name: "a later ask reads none again", asks: 2, verdict: delivery.ComposeCandidateComposed, reads: 1, + {name: "a later ask reads none again", asks: 2, verdict: nec.Composed, reads: 1, said: "read(s) waited on"}, - {name: "a read that does not answer is named at the bound", stuck: true, asks: 1, verdict: delivery.ComposeCandidateUnready, + {name: "a read that does not answer is named at the bound", stuck: true, asks: 1, verdict: nec.Unready, words: "waits on", said: "answered unready"}, } { t.Run(tc.name, func(t *testing.T) { @@ -241,9 +243,9 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { return get(rctx, bucket, k) } } - var a delivery.ComposeCandidateAnswer + var a nodeengine.ComposeCandidateAnswer for range tc.asks { - a = s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: key}) + a = s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: key}) } if a.Verdict != tc.verdict || !strings.Contains(a.Refused, tc.words) { t.Errorf("answered %s %q; want %s with %q", a.Verdict, a.Refused, tc.verdict, tc.words) -- 2.54.0 From a2313057c0981576ed6a4130e8944d201f7cbf23 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:02:44 +0100 Subject: [PATCH 13/73] ENG-9: a failed have carries the pull it waits on, read through node_haves The image, the seat and its holder, when the pull began, and the last retry with what triggered it ride in the failed have's pull-wait (mesh-sdk delivery.Have.PullWait, asked of the lead in j1-sdk-requests.patch), from the agent through the link's module fact to the have bucket; the agent's own read view of them goes with the verb. Rows in the agent's pull test and the have bucket's test; each failed without the code. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- cmd/mesh-host/nodeagent.go | 2 +- internal/agent/agent.go | 40 +++++++++++++----------------------- internal/agent/agent_test.go | 13 ++++++------ internal/link/have.go | 3 +++ internal/link/have_test.go | 8 ++++++++ internal/link/messages.go | 2 ++ 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index f0e67b7..57dfbde 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -120,7 +120,7 @@ func (h sessionHaves) Write(ctx context.Context, x agent.Have) { } h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase, Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits, - BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed}) + BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed, PullWait: x.PullWait}) } func (h sessionHaves) Removed(ctx context.Context, module string) { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 01a69b4..f42b747 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -68,6 +68,9 @@ type Have struct { // slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at // its own slot alone (issue 1126, C1). Sealed delivery.Seals + // PullWait is, on a failed have, the pull from a store its apply failed on and waits on the holder of (issue 1966 + // ENG-9, delivery.Have.PullWait): read through the have, as node_haves shows it. + PullWait *delivery.PullWait } // Haves is the node's have bucket as the agent uses it. @@ -1592,9 +1595,11 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo if words := a.pullWords(module); words != "" { detail += words } + wait := a.pullWaitOf(module) a.mu.Unlock() a.env.Say(fmt.Sprintf("applying %s failed: %s", what, res.Detail)) - a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: detail}) + a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: detail, + PullWait: wait}) if again != "" { a.askPullAgain(module, again) } @@ -1952,30 +1957,13 @@ func (a *Agent) Settled(holder string) { } } -// PullWaitView is one failed apply that waits on the holder of a store, as the engine's read verb pull-waits shows it -// (ENG-9, design 51): the module, the image and the seat it came through, whose settling it waits on, when the pull began, -// how many times that holder has settled since this engine started and when last, and what triggered the last retry. -type PullWaitView struct { - Module string `json:"module"` - Image string `json:"image"` - Seat string `json:"seat,omitempty"` - Holder string `json:"holder"` - Started time.Time `json:"pull-started"` - Settled int `json:"holder-settled"` - SettledAt time.Time `json:"holder-last-settled,omitzero"` - LastRetry string `json:"last-retry,omitempty"` - LastRetryAt time.Time `json:"last-retry-at,omitzero"` -} - -// PullWaits are the failed applies that wait on a store's holder, by module. -func (a *Agent) PullWaits() []PullWaitView { - a.mu.Lock() - defer a.mu.Unlock() - out := []PullWaitView{} - for module, pw := range a.pulls { - out = append(out, PullWaitView{Module: module, Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Started: pw.Started, - Settled: a.settledSeq[pw.Holder], SettledAt: a.settledAt[pw.Holder], LastRetry: pw.last, LastRetryAt: pw.lastAt}) +// pullWaitOf is the pull a failed apply of module waits on, as its have carries it (ENG-9): the image, the seat and its +// holder, when the pull began, and the last retry with what triggered it; nil when it waits on no holder. a.mu is held. +func (a *Agent) pullWaitOf(module string) *delivery.PullWait { + pw := a.pulls[module] + if pw == nil { + return nil } - sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module }) - return out + return &delivery.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last, + RetriedAt: pw.lastAt} } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index e6ee848..646856b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -521,10 +521,11 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { if n := len(b.appliedList()); n != 2 { t.Fatalf("a second failure after the same settled started a further apply (%d applies)", n) } - // The read verb's view: whom it waits on, how often that holder settled, and what triggered the last retry. - if w := b.ag.PullWaits(); len(w) != 1 || w[0].Module != "web" || w[0].Holder != "distribution" || w[0].Image != "reg/x@sha256:1" || - w[0].Settled != 1 || !strings.Contains(w[0].LastRetry, "distribution settled") { - t.Fatalf("the engine says it waits on %+v", w) + // The failed have carries the pull it waits on (ENG-9, read through node_haves): the image, whose settled it waits + // on, and what triggered the last retry. + if w := b.haves.last("web").PullWait; w == nil || w.Holder != "distribution" || w.Image != "reg/x@sha256:1" || + w.Began.IsZero() || !strings.Contains(w.Retried, "distribution settled") || w.RetriedAt.IsZero() { + t.Fatalf("the failed have says it waits on %+v", w) } // The next settled answers once more, and the pull works: the module runs, and waits on nothing. b.mu.Lock() @@ -534,8 +535,8 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { b.ag.Settled("distribution") eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 }) eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) - if w := b.ag.PullWaits(); len(w) != 0 { - t.Fatalf("a running module still waits on %+v", w) + if w := b.haves.last("web").PullWait; w != nil { + t.Fatalf("a running module's have still waits on %+v", w) } b.ag.Settled("distribution") time.Sleep(50 * time.Millisecond) diff --git a/internal/link/have.go b/internal/link/have.go index 7f1a2ed..23757db 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -387,6 +387,9 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { h.Phase = delivery.PhaseFailed } h.SealedAt = f.Sealed + if h.Phase == delivery.PhaseFailed { + h.PullWait = f.PullWait + } h.Health = s.healthOf(module, f) if h.Detail == "" { h.Detail = s.wordsOf(module, h.Health) diff --git a/internal/link/have_test.go b/internal/link/have_test.go index f0825b2..93916f9 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -581,6 +581,14 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( t.Fatalf("an apply lost the user list: %+v", got) } + // A failed apply waiting on a store's holder carries the pull it waits on in its have (ENG-9, read by node_haves). + wait := &delivery.PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution", + Began: time.Date(2026, 11, 4, 14, 0, 0, 0, time.UTC), Retried: "distribution settled"} + q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Failed: true, Detail: "the pull failed", PullWait: wait}) + if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseFailed || got.PullWait == nil || got.PullWait.Holder != "distribution" { + t.Fatalf("the failed have does not say the pull it waits on: %+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 == "" { diff --git a/internal/link/messages.go b/internal/link/messages.go index dd65cb3..59cebe1 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -645,4 +645,6 @@ type ModuleFact struct { Kept []string // Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt). Sealed delivery.Seals + // PullWait is, on a failed fact, the pull from a store its apply waits on (delivery.Have.PullWait, issue 1966 ENG-9). + PullWait *delivery.PullWait } -- 2.54.0 From 9740238ec9f6f59edc75fc968a02ef01337f9776 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:20:46 +0100 Subject: [PATCH 14/73] F-host 1: ignore the binaries go build leaves in cmd/, so a built node-engine is never committed again (review of issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0570bbc..4f5c290 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ # The placeholder `make bootstrap` moves aside while a saved image is embedded. Ignored so an # interrupted release build cannot commit a twenty-megabyte tar by accident. /internal/image/builder.tar.placeholder +/cmd/mesh-host/mesh-host +/cmd/mesh-bootstrap/mesh-bootstrap -- 2.54.0 From 20614822dff667a4c30d00cdaa47915cd5e31ce0 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:24:14 +0100 Subject: [PATCH 15/73] F-host 2, ENG-5: the bootstrap's one-off push credential goes to distribution's account, mode 0600, only once the node-engine made that account and its state directory, so the holder's program can read it (review of issue 1966) The owner is the account the manifest names for the directory that holds the file; the bootstrap no longer makes that directory itself, and a run that reaches it first is refused naming what it waits on. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/bootstrap/bootstrap.go | 7 +-- internal/bootstrap/push_credential.go | 81 +++++++++++++++++++++++---- internal/bootstrap/trust_test.go | 35 ++++++++++-- 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 2d4b2ff..b95905c 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -159,9 +159,8 @@ type Options struct { // PushCredentialFile is where the bootstrap writes its one-off push credential for the store's holder to find, the // holder's state file `bootstrap-push` on the node that holds it (design 51 *The move*, issue 1966 ENG-5); empty writes - // none. PushCredentialOwner is the user id and group id the file is given when the holder's account is known. - PushCredentialFile string - PushCredentialOwner *[2]int + // none. The file goes to the account that owns that directory in the holder's manifest, once the node-engine made both. + PushCredentialFile string // Host is the `mesh-host` binary on this machine — the program that enrols and then holds the // machine to what the mesh says. The installer runs it; it does not contain it. @@ -826,7 +825,7 @@ func RegistryAndPublish(ctx context.Context, o Options, d Deps, sys system.Syste // The one-off credential the holders' first images are pushed to the store's front with, written for its holder to find // and forgotten when this run ends; the holder discards the file once it answers its first publish (ENG-5). if o.PushCredentialFile != "" { - cred, err := MintPushCredential(o.PushCredentialFile, o.PushCredentialOwner) + cred, err := WritePushCredential(o.Catalogue, o.PushCredentialFile) if err != nil { return Published{}, failed(StepRegistry, err) } diff --git a/internal/bootstrap/push_credential.go b/internal/bootstrap/push_credential.go index 5331609..9134836 100644 --- a/internal/bootstrap/push_credential.go +++ b/internal/bootstrap/push_credential.go @@ -3,9 +3,13 @@ package bootstrap import ( "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "os" + "os/user" "path/filepath" + "strconv" + "syscall" ) // PushCredential is the bootstrap's one-off credential for the holders' first images (design 51 *The move*; issue 1966 ENG-5): @@ -26,26 +30,81 @@ func (c *PushCredential) Token() string { return c.token } // Forget drops the token from this run's memory. The file is the holder's to remove. func (c *PushCredential) Forget() { c.token = "" } -// MintPushCredential makes a token and writes it to file, mode 0600, owned by owner (user id, group id) when given; the -// directory is made if it is not there. A file that is there is replaced: a credential left by an earlier run is never reused. -func MintPushCredential(file string, owner *[2]int) (*PushCredential, error) { +// lookupAccount gives an account's user id and group id. A variable so a test can name an account this machine has. +var lookupAccount = func(name string) (uid, gid int, err error) { + u, err := user.Lookup(name) + if err != nil { + return 0, 0, err + } + if uid, err = strconv.Atoi(u.Uid); err != nil { + return 0, 0, err + } + gid, err = strconv.Atoi(u.Gid) + return uid, gid, err +} + +// WritePushCredential writes the one-off credential for the store's holder, read from its manifest in catalogue: the file +// goes into the holder's directory resource that holds file's path, given to the account that directory names as owner, +// and only once the node-engine has made that account and that directory, owned by it. Until then the holder's program, +// which runs as that account, could not read it, so it is refused rather than written as root's. +func WritePushCredential(catalogue, file string) (*PushCredential, error) { + manifest, err := readManifest(catalogue, RegistryModule) + if err != nil { + return nil, err + } + var m struct { + Resources []struct { + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + Owner string `json:"owner"` + } `json:"resources"` + } + if err := json.Unmarshal(manifest, &m); err != nil { + return nil, fmt.Errorf("the %s manifest is not readable as JSON: %w", RegistryModule, err) + } + dir, owner, account := filepath.Dir(file), "", false + for _, r := range m.Resources { + if r.Type == "directory" && filepath.Clean(r.Path) == dir { + owner = r.Owner + } + } + for _, r := range m.Resources { + account = account || (owner != "" && r.Type == "user" && r.Name == owner) + } + if !account { + return nil, fmt.Errorf("the push credential %s has no account to go to: the %s manifest declares no directory %s "+ + "owned by an account it declares, so its program could not read the file", file, RegistryModule, dir) + } + uid, gid, err := lookupAccount(owner) + if err != nil { + return nil, fmt.Errorf("the push credential waits on the account %s, which the node-engine has not made: %w", owner, err) + } + st, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("the push credential waits on %s's state directory %s, which the node-engine has not made: %w", owner, dir, err) + } + if sys, ok := st.Sys().(*syscall.Stat_t); !ok || int(sys.Uid) != uid { + return nil, fmt.Errorf("the push credential waits on %s owning its state directory %s, which the node-engine has not yet given it", owner, dir) + } + return MintPushCredential(file, uid, gid) +} + +// MintPushCredential makes a token and writes it to file, mode 0600, owned by the user id and group id given, into a +// directory that must be there. A file that is there is replaced: a credential left by an earlier run is never reused. +func MintPushCredential(file string, uid, gid int) (*PushCredential, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return nil, fmt.Errorf("a push credential cannot be made: %w", err) } token := hex.EncodeToString(raw) - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - return nil, fmt.Errorf("the push credential's directory %s cannot be made: %w", filepath.Dir(file), err) - } tmp := file + ".new" if err := os.WriteFile(tmp, []byte(token+"\n"), 0o600); err != nil { return nil, fmt.Errorf("the push credential cannot be written to %s: %w", file, err) } - if owner != nil { - if err := os.Chown(tmp, owner[0], owner[1]); err != nil { - os.Remove(tmp) - return nil, fmt.Errorf("the push credential cannot be given to the store's holder: %w", err) - } + if err := os.Chown(tmp, uid, gid); err != nil { + os.Remove(tmp) + return nil, fmt.Errorf("the push credential cannot be given to the store's holder: %w", err) } if err := os.Rename(tmp, file); err != nil { os.Remove(tmp) diff --git a/internal/bootstrap/trust_test.go b/internal/bootstrap/trust_test.go index 4ca6007..6fd6533 100644 --- a/internal/bootstrap/trust_test.go +++ b/internal/bootstrap/trust_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "strings" + "syscall" "testing" "time" @@ -227,8 +228,29 @@ func TestARangeTheRuntimeTrustsCoversAnAddressInIt(t *testing.T) { // moving the trust after the push, or dropping it, fails this test. func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { o, runtime, sys := trustSetup(t, `{"data-root":"/srv/docker"}`) - o.Catalogue = catalogueWith(t, RegistryModule, upstreamRegistryManifest) + // The holder's state directory and its account, as distribution's manifest declares them (ENG-5). + stateDir := filepath.Join(t.TempDir(), "registry") + o.Catalogue = catalogueWith(t, RegistryModule, strings.Replace(upstreamRegistryManifest, + `{"id": "state", "type": "directory", "path": "/var/lib/mesh/registry", "mode": "0700"}`, + `{"id": "account", "type": "user", "name": "distribution"}, + {"id": "state", "type": "directory", "path": "`+stateDir+`", "mode": "0700", "owner": "distribution"}`, 1)) o.Registry = "192.0.2.10:5100" + asked, was := []string{}, lookupAccount + lookupAccount = func(name string) (int, int, error) { + asked = append(asked, name) + return os.Getuid(), os.Getgid(), nil + } + t.Cleanup(func() { lookupAccount = was }) + o.PushCredentialFile = filepath.Join(stateDir, "bootstrap-push") + // Before the node-engine made distribution's state directory, nothing is written: the program could not read it. + if _, err := WritePushCredential(o.Catalogue, o.PushCredentialFile); err == nil { + t.Errorf("the push credential was written before its holder's state directory existed") + } else if _, err := os.Stat(stateDir); err == nil { + t.Errorf("the bootstrap made the holder's state directory itself") + } + if err := os.Mkdir(stateDir, 0o700); err != nil { + t.Fatal(err) + } pushed := false mesh := aMeshThatAgrees(nil) @@ -266,7 +288,6 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { var result Result // And the one-off credential for the holders' first images (ENG-5): written once, mode 0600, one line of 64 hex digits, // named in the result and never put in it. - o.PushCredentialFile = filepath.Join(t.TempDir(), "registry", "bootstrap-push") published, err := RegistryAndPublish(context.Background(), o, d, sys, controlPlane{container: "temp-mesh-controller", run: run, timeout: time.Second}, held, &result, func(string) {}) if err != nil { @@ -293,13 +314,17 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { } if raw, err := os.ReadFile(o.PushCredentialFile); err != nil || !regexp.MustCompile(`^[0-9a-f]{64}\n$`).Match(raw) { t.Errorf("the push credential is %q (%v)", raw, err) - } else if st, _ := os.Stat(o.PushCredentialFile); st.Mode().Perm() != 0o600 { - t.Errorf("the push credential is mode %v", st.Mode().Perm()) + } else if st, _ := os.Stat(o.PushCredentialFile); st.Mode().Perm() != 0o600 || + int(st.Sys().(*syscall.Stat_t).Uid) != os.Getuid() || int(st.Sys().(*syscall.Stat_t).Gid) != os.Getgid() { + t.Errorf("the push credential is mode %v, owner %d:%d", st.Mode().Perm(), st.Sys().(*syscall.Stat_t).Uid, st.Sys().(*syscall.Stat_t).Gid) + } + if fmt.Sprint(asked) != "[distribution distribution]" { + t.Errorf("the push credential's owner was asked as %v, not the account distribution's state directory names", asked) } if result.PushCredentialFile != o.PushCredentialFile { t.Errorf("the result names %q", result.PushCredentialFile) } - if again, err := MintPushCredential(o.PushCredentialFile, nil); err != nil || again.Token() == "" { + if again, err := MintPushCredential(o.PushCredentialFile, os.Getuid(), os.Getgid()); err != nil || again.Token() == "" { t.Errorf("a credential of an earlier run was not replaced: %v", err) } else if again.Forget(); again.Token() != "" { t.Errorf("a forgotten credential still holds its token") -- 2.54.0 From ded92a8137d79da36bde41a17bb846cca9cb5fdd Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:26:16 +0100 Subject: [PATCH 16/73] F-host 3: the agent's tests of a pull waiting on its holder wait for the loop to come to rest, not 50-100 ms, before they count applies (review of issue 1966) waitIdle returns once every change sent to the loop is taken in and applied and no backoff wait runs, so a retry the clock or a wrong settled would start is counted, with no real sleep. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/agent/agent.go | 43 +++++++++++++++++++++++++++++++++++- internal/agent/agent_test.go | 21 +++++++++++++----- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f42b747..1367c9f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -21,6 +21,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -260,6 +261,10 @@ type Agent struct { again chan againAsk transient chan struct{} stopped chan struct{} + // quiet is asked by waitIdle: the loop closes the channel it is given once nothing waits to be taken in or applied. + quiet chan chan struct{} + // armed counts the backoff waits running: the loop is not idle while one may still ask an apply again. + armed atomic.Int32 } type againAsk struct { @@ -301,7 +306,7 @@ func New(env Env) *Agent { attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{}, pulls: map[string]*pullWait{}, settledAt: map[string]time.Time{}, settledSeq: map[string]int{}, retry: make(chan []string, 8), reconcile: make(chan []string, 64), failedAgain: make(chan string, 8), tries: map[string]int{}, again: make(chan againAsk, 64), - transient: make(chan struct{}, 1), stopped: make(chan struct{})} + transient: make(chan struct{}, 1), stopped: make(chan struct{}), quiet: make(chan chan struct{})} } // Run follows the wants until ctx ends. The keys there when it starts are taken first, the node's own composition @@ -355,6 +360,19 @@ func (a *Agent) Run(ctx context.Context) error { dirty[m] = true } a.mu.Unlock() + case done := <-a.quiet: + // Idle only when every change already sent is taken in and applied; asked again otherwise, after the rest. + if len(dirty) == 0 && len(a.retry)+len(a.reconcile)+len(a.failedAgain)+len(a.again)+len(a.transient)+len(wants)+len(comps) == 0 && + a.armed.Load() == 0 { + close(done) + } else { + go func() { + select { + case a.quiet <- done: + case <-a.stopped: + } + }() + } case ask := <-a.again: // The wait of a transiently failed apply ran out: tried again from the keys it has, if nothing tried it since. a.mu.Lock() @@ -413,6 +431,27 @@ func (a *Agent) Run(ctx context.Context) error { } } +// waitIdle returns once the loop has taken in and applied every change sent to it before the call (a want or composition +// put, a settled heard, a retry asked), with no backoff wait still running, so a caller can count what was applied with no clock; false when ctx ends first. +func (a *Agent) waitIdle(ctx context.Context) bool { + done := make(chan struct{}) + select { + case a.quiet <- done: + case <-ctx.Done(): + return false + case <-a.stopped: + return false + } + select { + case <-done: + return true + case <-ctx.Done(): + return false + case <-a.stopped: + return false + } +} + // Retry asks for the modules to be applied again from the keys they have, though nothing changed: what ADR 0360 §11 // needs for a container that waits because it said busy. It never blocks; a retry asked while eight wait is dropped, and // asked again by its caller's next look. @@ -1571,7 +1610,9 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo if a.env.Backoff != nil { wait := a.env.Backoff(try) when = "in " + wait.String() + a.armed.Add(1) time.AfterFunc(wait, func() { + defer a.armed.Add(-1) select { case a.again <- againAsk{module, gen}: case <-a.stopped: diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 646856b..92137b6 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -193,6 +193,17 @@ func newBed(t *testing.T) *bed { return b } +// idle waits until the agent has taken in and applied everything sent to it so far: what is counted after it is all there +// will be, with no clock. +func (b *bed) idle() { + b.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if !b.ag.waitIdle(ctx) { + b.t.Fatalf("the agent's loop did not come to rest") + } +} + func (b *bed) run() (stop func()) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) @@ -499,13 +510,13 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { h := b.haves.last("web") return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled") }) - time.Sleep(100 * time.Millisecond) + b.idle() if n := len(b.appliedList()); n != 1 { t.Fatalf("a failed pull was tried again by the clock (%d applies)", n) } // A settled of another module starts nothing. b.ag.Settled("gitea") - time.Sleep(50 * time.Millisecond) + b.idle() if n := len(b.appliedList()); n != 1 { t.Fatalf("a settled of another module applied the module (%d applies)", n) } @@ -517,7 +528,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { h := b.haves.last("web") return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled") }) - time.Sleep(100 * time.Millisecond) + b.idle() if n := len(b.appliedList()); n != 2 { t.Fatalf("a second failure after the same settled started a further apply (%d applies)", n) } @@ -539,7 +550,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { t.Fatalf("a running module's have still waits on %+v", w) } b.ag.Settled("distribution") - time.Sleep(50 * time.Millisecond) + b.idle() if n := len(b.appliedList()); n != 3 { t.Fatalf("a running module was applied again by a settled (%d applies)", n) } @@ -577,7 +588,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { b.comps.put(ck, craw) b.wants.put(wk, wraw) eventually(t, "the settled heard during the pull was missed", func() bool { return len(b.appliedList()) == 2 }) - time.Sleep(100 * time.Millisecond) + b.idle() if n := len(b.appliedList()); n != 2 { t.Fatalf("the race retry failing again started a further apply (%d applies)", n) } -- 2.54.0 From d3ee49d6f37fbc8b743d194a7179afb28e56e2ad Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:29:56 +0100 Subject: [PATCH 17/73] F-host 2, ENG-5: on a fresh mesh the bootstrap pushes the holders' own images to the store on its loopback with no credential, and names them under the seat's reach (lead's design limit, review of issue 1966) The store's front is distribution's program, a bundle the mesh builds, so nothing serves it before the builder: the bootstrap's /v2/ wait and its pushes now go to the loopback port the store's manifest publishes, as the publish contract allows on the store's own node. The one-off credential and its file go: the runtime could not carry it on a push, and no push needs it. The tags list is asked for the repository being published, not always the control plane's. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- cmd/mesh-bootstrap/main.go | 4 +- internal/bootstrap/bootstrap.go | 27 +----- internal/bootstrap/publish.go | 37 +++++---- internal/bootstrap/publish_test.go | 3 +- internal/bootstrap/push_credential.go | 114 -------------------------- internal/bootstrap/registry.go | 49 ++++++++++- internal/bootstrap/trust_test.go | 68 ++++++--------- 7 files changed, 102 insertions(+), 200 deletions(-) delete mode 100644 internal/bootstrap/push_credential.go diff --git a/cmd/mesh-bootstrap/main.go b/cmd/mesh-bootstrap/main.go index 46ccad0..369a744 100644 --- a/cmd/mesh-bootstrap/main.go +++ b/cmd/mesh-bootstrap/main.go @@ -188,9 +188,7 @@ func parseArgs(args []string) (string, bootstrap.Options, bool, error) { Out: defaultOut, State: store.DefaultPath, Registry: defaultRegistry, - // Where the store's holder reads the one-off credential the holders' first images are pushed with (design 51, ENG-5). - PushCredentialFile: "/var/lib/mesh/registry/bootstrap-push", - Host: defaultHost, + Host: defaultHost, // The machine's own name, because that is what a person already calls it and an installer // inventing a different one would leave the mesh naming a machine nobody recognises. It is // read here rather than inside the bootstrap so that --node overrides a fact rather than a diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index b95905c..5835bc5 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -157,11 +157,6 @@ type Options struct { // an address the others can reach. Registry string - // PushCredentialFile is where the bootstrap writes its one-off push credential for the store's holder to find, the - // holder's state file `bootstrap-push` on the node that holds it (design 51 *The move*, issue 1966 ENG-5); empty writes - // none. The file goes to the account that owns that directory in the holder's manifest, once the node-engine made both. - PushCredentialFile string - // Host is the `mesh-host` binary on this machine — the program that enrols and then holds the // machine to what the mesh says. The installer runs it; it does not contain it. Host string @@ -301,12 +296,10 @@ type Result struct { Agent string `json:"host-agent,omitempty"` // Registry is the mesh's own artifact store, once it answers. - Registry string `json:"registry,omitempty"` - // PushCredentialFile is where this run left its one-off push credential (never the credential itself). - PushCredentialFile string `json:"push-credential-file,omitempty"` - RegistryReplied int `json:"registry-replied,omitempty"` - RegistryKnown bool `json:"registry-already-registered,omitempty"` - RegistryRunning string `json:"registry-container,omitempty"` + Registry string `json:"registry,omitempty"` + RegistryReplied int `json:"registry-replied,omitempty"` + RegistryKnown bool `json:"registry-already-registered,omitempty"` + RegistryRunning string `json:"registry-container,omitempty"` // RegistryTrusted are the names the runtime was told to trust the registry by, before the push. RegistryTrusted []string `json:"registry-trusted,omitempty"` PublishedAs string `json:"control-plane-image,omitempty"` @@ -822,18 +815,6 @@ func RegistryAndPublish(ctx context.Context, o Options, d Deps, sys system.Syste if err != nil { return Published{}, failed(StepRegistry, err) } - // The one-off credential the holders' first images are pushed to the store's front with, written for its holder to find - // and forgotten when this run ends; the holder discards the file once it answers its first publish (ENG-5). - if o.PushCredentialFile != "" { - cred, err := WritePushCredential(o.Catalogue, o.PushCredentialFile) - if err != nil { - return Published{}, failed(StepRegistry, err) - } - defer cred.Forget() - result.PushCredentialFile = cred.File - say(" a one-off credential for the holders' first images is at " + cred.File + "; the store's holder discards it once it serves publish") - } - // ---- 8. publish ----------------------------------------------------------------------- say("publish — the control plane's image gets its first manifest digest") // **The image this mesh built, not the one the installer carried.** The carried one is the diff --git a/internal/bootstrap/publish.go b/internal/bootstrap/publish.go index 561ce92..97dbd13 100644 --- a/internal/bootstrap/publish.go +++ b/internal/bootstrap/publish.go @@ -69,17 +69,24 @@ func PublishControlPlane(ctx context.Context, o Options, d Deps, imageID string, func publishAs(ctx context.Context, o Options, d Deps, imageID, repository string, say func(string)) (Published, error) { - remote := o.Registry + "/" + repository + // Pushed to the store's own loopback with no credential (StoreLoopback); recorded under the seat's reach, the + // address every node pulls by, with the digest the store gave. + loopback, err := StoreLoopback(o.Catalogue) + if err != nil { + return Published{}, err + } + remote := loopback + "/" + repository out := Published{Tagged: remote + ":" + genesisTag} + reach := func(digest string) string { return o.Registry + "/" + repository + strings.TrimPrefix(digest, remote) } // Asked first. A digest already served is a fact about the registry, and re-pushing an image // the registry already holds is asking it to store what it already has under the name it // already has. - if held, err := digestOf(ctx, o, d, remote); err != nil { + if held, err := digestOf(ctx, o, d, loopback, repository); err != nil { return out, err } else if held != "" { - out.Reference, out.Already = held, true - say(" already published " + held) + out.Reference, out.Already = reach(held), true + say(" already published " + out.Reference) return out, nil } @@ -93,12 +100,12 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin "over the mesh's own network, which is already the encrypted and authenticated "+ "thing. A runtime refusing it for being insecure is refusing a registry on %s, "+ "which it does not do for a loopback address", - out.Tagged, err, o.Registry) + out.Tagged, err, loopback) } // Read back, from the registry's own answer rather than computed here. What matters is what // the registry will serve for that reference, and only it can say (novox/hq ADR 0018). - pinned, err := digestOf(ctx, o, d, remote) + pinned, err := digestOf(ctx, o, d, loopback, repository) if err != nil { return out, err } @@ -107,10 +114,10 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin "%s was pushed and the registry does not serve it.\n"+ "The next step names this module by the digest this was supposed to produce, so "+ "there is nothing to name. Check `docker push` and "+ - "http://%s/v2/%s/tags/list", out.Tagged, o.Registry, repository) + "http://%s/v2/%s/tags/list", out.Tagged, loopback, repository) } - out.Reference = pinned - say(" published " + pinned) + out.Reference = reach(pinned) + say(" published " + out.Reference) return out, nil } @@ -121,13 +128,13 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin // whether the registry still has it — a registry whose volume was recreated would leave the // runtime remembering a digest nothing serves, and the module registered against it would pin the // mesh to an image that cannot be pulled. -func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, error) { +func digestOf(ctx context.Context, o Options, d Deps, store, repository string) (string, error) { + remote := store + "/" + repository asking, cancel := context.WithTimeout(ctx, o.Timeout) - status, body, err := d.Fetch(asking, - "http://"+o.Registry+"/v2/"+ControlPlaneRepository+"/tags/list") + status, body, err := d.Fetch(asking, "http://"+store+"/v2/"+repository+"/tags/list") cancel() if err != nil { - return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", o.Registry, err) + return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", store, err) } if status == http.StatusNotFound { // Nothing has ever been pushed under this name. An answer, not a failure. @@ -135,14 +142,14 @@ func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, er } if status != http.StatusOK { return "", fmt.Errorf("the registry answered %d when asked what it holds for %s", - status, ControlPlaneRepository) + status, repository) } var listed struct { Tags []string `json:"tags"` } if err := json.Unmarshal([]byte(body), &listed); err != nil { return "", fmt.Errorf("the registry's answer about %s is not readable: %w", - ControlPlaneRepository, err) + repository, err) } if !contains(listed.Tags, genesisTag) { return "", nil diff --git a/internal/bootstrap/publish_test.go b/internal/bootstrap/publish_test.go index adeaf95..6120136 100644 --- a/internal/bootstrap/publish_test.go +++ b/internal/bootstrap/publish_test.go @@ -22,7 +22,8 @@ func publishing(t *testing.T, fetch func(string) (int, string, error), // Each value named before it is returned: a return of several composite literals spanning lines is laid // out differently by the gofmt of one Go release and the next, and the build seat's is the one that // judges (novox/hq issue 286). - options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0} + options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0, + Catalogue: catalogueWith(t, RegistryModule, upstreamRegistryManifest)} deps := Deps{ Run: runtime.run, Fetch: func(_ context.Context, url string) (int, string, error) { diff --git a/internal/bootstrap/push_credential.go b/internal/bootstrap/push_credential.go deleted file mode 100644 index 9134836..0000000 --- a/internal/bootstrap/push_credential.go +++ /dev/null @@ -1,114 +0,0 @@ -package bootstrap - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "os/user" - "path/filepath" - "strconv" - "syscall" -) - -// PushCredential is the bootstrap's one-off credential for the holders' first images (design 51 *The move*; issue 1966 ENG-5): -// a bearer token of 32 random bytes, hex, on one line, made for one fresh-mesh run. No vault and no bus holds it: this run -// holds it and writes it once, to the file the store's holder reads on each write (`Authorization: Bearer ` on an OCI -// write to the store's port, from any node); the holder removes the file when it answers its first `publish`, and this run -// forgets the token when it ends. On the holder's own node the bootstrap may push to the registry container's loopback with -// no credential at all. -type PushCredential struct { - // File is where the token was written. - File string - token string -} - -// Token is the bearer token, for a push through the store's front from another node. -func (c *PushCredential) Token() string { return c.token } - -// Forget drops the token from this run's memory. The file is the holder's to remove. -func (c *PushCredential) Forget() { c.token = "" } - -// lookupAccount gives an account's user id and group id. A variable so a test can name an account this machine has. -var lookupAccount = func(name string) (uid, gid int, err error) { - u, err := user.Lookup(name) - if err != nil { - return 0, 0, err - } - if uid, err = strconv.Atoi(u.Uid); err != nil { - return 0, 0, err - } - gid, err = strconv.Atoi(u.Gid) - return uid, gid, err -} - -// WritePushCredential writes the one-off credential for the store's holder, read from its manifest in catalogue: the file -// goes into the holder's directory resource that holds file's path, given to the account that directory names as owner, -// and only once the node-engine has made that account and that directory, owned by it. Until then the holder's program, -// which runs as that account, could not read it, so it is refused rather than written as root's. -func WritePushCredential(catalogue, file string) (*PushCredential, error) { - manifest, err := readManifest(catalogue, RegistryModule) - if err != nil { - return nil, err - } - var m struct { - Resources []struct { - Type string `json:"type"` - Name string `json:"name"` - Path string `json:"path"` - Owner string `json:"owner"` - } `json:"resources"` - } - if err := json.Unmarshal(manifest, &m); err != nil { - return nil, fmt.Errorf("the %s manifest is not readable as JSON: %w", RegistryModule, err) - } - dir, owner, account := filepath.Dir(file), "", false - for _, r := range m.Resources { - if r.Type == "directory" && filepath.Clean(r.Path) == dir { - owner = r.Owner - } - } - for _, r := range m.Resources { - account = account || (owner != "" && r.Type == "user" && r.Name == owner) - } - if !account { - return nil, fmt.Errorf("the push credential %s has no account to go to: the %s manifest declares no directory %s "+ - "owned by an account it declares, so its program could not read the file", file, RegistryModule, dir) - } - uid, gid, err := lookupAccount(owner) - if err != nil { - return nil, fmt.Errorf("the push credential waits on the account %s, which the node-engine has not made: %w", owner, err) - } - st, err := os.Stat(dir) - if err != nil { - return nil, fmt.Errorf("the push credential waits on %s's state directory %s, which the node-engine has not made: %w", owner, dir, err) - } - if sys, ok := st.Sys().(*syscall.Stat_t); !ok || int(sys.Uid) != uid { - return nil, fmt.Errorf("the push credential waits on %s owning its state directory %s, which the node-engine has not yet given it", owner, dir) - } - return MintPushCredential(file, uid, gid) -} - -// MintPushCredential makes a token and writes it to file, mode 0600, owned by the user id and group id given, into a -// directory that must be there. A file that is there is replaced: a credential left by an earlier run is never reused. -func MintPushCredential(file string, uid, gid int) (*PushCredential, error) { - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return nil, fmt.Errorf("a push credential cannot be made: %w", err) - } - token := hex.EncodeToString(raw) - tmp := file + ".new" - if err := os.WriteFile(tmp, []byte(token+"\n"), 0o600); err != nil { - return nil, fmt.Errorf("the push credential cannot be written to %s: %w", file, err) - } - if err := os.Chown(tmp, uid, gid); err != nil { - os.Remove(tmp) - return nil, fmt.Errorf("the push credential cannot be given to the store's holder: %w", err) - } - if err := os.Rename(tmp, file); err != nil { - os.Remove(tmp) - return nil, fmt.Errorf("the push credential cannot be put at %s: %w", file, err) - } - return &PushCredential{File: file, token: token}, nil -} diff --git a/internal/bootstrap/registry.go b/internal/bootstrap/registry.go index 359007f..966d612 100644 --- a/internal/bootstrap/registry.go +++ b/internal/bootstrap/registry.go @@ -95,9 +95,14 @@ func InstallRegistry(ctx context.Context, o Options, d Deps, control controlPlan return out, nil } -// waitForTheRegistry asks `/v2/` until it answers. +// waitForTheRegistry asks the store's own `/v2/`, on its loopback (StoreLoopback), until it answers: the pushes that +// follow go there, and the store's front is not served yet on a fresh mesh. func waitForTheRegistry(ctx context.Context, d Deps, o Options, say func(string)) (int, error) { - where := "http://" + o.Registry + "/v2/" + loopback, err := StoreLoopback(o.Catalogue) + if err != nil { + return 0, err + } + where := "http://" + loopback + "/v2/" deadline := time.Now().Add(o.Wait) var last string @@ -207,3 +212,43 @@ func containerIn(manifest []byte, id string) (name, image string, err error) { "the %s module declares no container %q, so the installer does not know what to wait for. "+ "It declares: %s", m.Module, id, strings.Join(containers, ", ")) } + +// storeContainerPort is the port the registry image listens on inside its container (Distribution's own default). +const storeContainerPort = "5000" + +// StoreLoopback is host:port where this machine reaches the store's container itself, read from the port its manifest +// publishes for it: on a fresh mesh the bootstrap pushes the holders' own images there, with no credential, because the +// store's front (distribution's program, a bundle the mesh builds) is not served until the builder has built it (the +// publish contract allows a push on the store's own node to its loopback). A port published on every address is reached +// on 127.0.0.1. What the bootstrap records is named under the seat's reach (Options.Registry), never this address. +func StoreLoopback(catalogue string) (string, error) { + manifest, err := readManifest(catalogue, RegistryModule) + if err != nil { + return "", err + } + var m struct { + Resources []struct { + ID string `json:"id"` + Ports []string `json:"ports"` + } `json:"resources"` + } + if err := json.Unmarshal(manifest, &m); err != nil { + return "", fmt.Errorf("the %s manifest is not readable as JSON: %w", RegistryModule, err) + } + for _, r := range m.Resources { + if r.ID != registryResource { + continue + } + for _, p := range r.Ports { + parts := strings.Split(strings.SplitN(p, "/", 2)[0], ":") + switch { + case len(parts) == 3 && parts[2] == storeContainerPort: + return parts[0] + ":" + parts[1], nil + case len(parts) == 2 && parts[1] == storeContainerPort: + return "127.0.0.1:" + parts[0], nil + } + } + } + return "", fmt.Errorf("the %s module's container %q publishes no port for the store's %s, so the bootstrap cannot "+ + "reach the store to push to it", RegistryModule, registryResource, storeContainerPort) +} diff --git a/internal/bootstrap/trust_test.go b/internal/bootstrap/trust_test.go index 6fd6533..9ec16d4 100644 --- a/internal/bootstrap/trust_test.go +++ b/internal/bootstrap/trust_test.go @@ -7,9 +7,7 @@ import ( "net/http" "os" "path/filepath" - "regexp" "strings" - "syscall" "testing" "time" @@ -228,29 +226,15 @@ func TestARangeTheRuntimeTrustsCoversAnAddressInIt(t *testing.T) { // moving the trust after the push, or dropping it, fails this test. func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { o, runtime, sys := trustSetup(t, `{"data-root":"/srv/docker"}`) - // The holder's state directory and its account, as distribution's manifest declares them (ENG-5). - stateDir := filepath.Join(t.TempDir(), "registry") - o.Catalogue = catalogueWith(t, RegistryModule, strings.Replace(upstreamRegistryManifest, - `{"id": "state", "type": "directory", "path": "/var/lib/mesh/registry", "mode": "0700"}`, - `{"id": "account", "type": "user", "name": "distribution"}, - {"id": "state", "type": "directory", "path": "`+stateDir+`", "mode": "0700", "owner": "distribution"}`, 1)) + // The store as distribution's manifest declares it: its container on this machine's loopback, its front not yet served, + // and its state directory, where no credential is ever written (design 51 *On a fresh mesh*, a design limit of issue 1966). + stateDir := t.TempDir() + o.Catalogue = catalogueWith(t, RegistryModule, strings.NewReplacer( + `"path": "/var/lib/mesh/registry"`, `"path": "`+stateDir+`"`, + `"ports": ["5000:5000"]`, `"ports": ["127.0.0.1:5001:5000"]`).Replace(upstreamRegistryManifest)) o.Registry = "192.0.2.10:5100" - asked, was := []string{}, lookupAccount - lookupAccount = func(name string) (int, int, error) { - asked = append(asked, name) - return os.Getuid(), os.Getgid(), nil - } - t.Cleanup(func() { lookupAccount = was }) - o.PushCredentialFile = filepath.Join(stateDir, "bootstrap-push") - // Before the node-engine made distribution's state directory, nothing is written: the program could not read it. - if _, err := WritePushCredential(o.Catalogue, o.PushCredentialFile); err == nil { - t.Errorf("the push credential was written before its holder's state directory existed") - } else if _, err := os.Stat(stateDir); err == nil { - t.Errorf("the bootstrap made the holder's state directory itself") - } - if err := os.Mkdir(stateDir, 0o700); err != nil { - t.Fatal(err) - } + const loopback = "127.0.0.1:5001" + var fetched []string pushed := false mesh := aMeshThatAgrees(nil) @@ -264,7 +248,8 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { case "tag": return "", nil case "push": - trusted := false + // The runtime trusts a loopback registry with no configuration; any other only once told. + trusted := strings.HasPrefix(args[1], "127.0.0.1:") for _, n := range runtime.trusted { trusted = trusted || strings.HasPrefix(args[1], n+"/") } @@ -274,20 +259,19 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { pushed = true return "", nil case "image": - return `["` + o.Registry + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil + return `["` + loopback + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil } } return mesh(name, args) } d := Deps{Run: run, Fetch: func(_ context.Context, url string) (int, string, error) { + fetched = append(fetched, url) if strings.HasSuffix(url, "/v2/") || pushed { return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil } return http.StatusNotFound, "", nil }} var result Result - // And the one-off credential for the holders' first images (ENG-5): written once, mode 0600, one line of 64 hex digits, - // named in the result and never put in it. published, err := RegistryAndPublish(context.Background(), o, d, sys, controlPlane{container: "temp-mesh-controller", run: run, timeout: time.Second}, held, &result, func(string) {}) if err != nil { @@ -312,22 +296,22 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { t.Errorf("the trust was not written (%d) and confirmed (%d) before the first tag or push (%d): %v", reloaded, confirmed, firstPush, runtime.commands) } - if raw, err := os.ReadFile(o.PushCredentialFile); err != nil || !regexp.MustCompile(`^[0-9a-f]{64}\n$`).Match(raw) { - t.Errorf("the push credential is %q (%v)", raw, err) - } else if st, _ := os.Stat(o.PushCredentialFile); st.Mode().Perm() != 0o600 || - int(st.Sys().(*syscall.Stat_t).Uid) != os.Getuid() || int(st.Sys().(*syscall.Stat_t).Gid) != os.Getgid() { - t.Errorf("the push credential is mode %v, owner %d:%d", st.Mode().Perm(), st.Sys().(*syscall.Stat_t).Uid, st.Sys().(*syscall.Stat_t).Gid) + // The push went to the store on its loopback, and nothing the run asked or pushed reached the seat's reach; the + // reference is named under the reach all the same, and no credential file was written for the store's holder. + for _, c := range runtime.commands { + if strings.HasPrefix(c, "docker tag") || strings.HasPrefix(c, "docker push") { + if !strings.Contains(c, " "+loopback+"/mesh-controller:") { + t.Errorf("a push not to the loopback store: %q", c) + } + } } - if fmt.Sprint(asked) != "[distribution distribution]" { - t.Errorf("the push credential's owner was asked as %v, not the account distribution's state directory names", asked) + for _, u := range fetched { + if !strings.HasPrefix(u, "http://"+loopback+"/v2/") { + t.Errorf("the run asked %s, not the loopback store", u) + } } - if result.PushCredentialFile != o.PushCredentialFile { - t.Errorf("the result names %q", result.PushCredentialFile) - } - if again, err := MintPushCredential(o.PushCredentialFile, os.Getuid(), os.Getgid()); err != nil || again.Token() == "" { - t.Errorf("a credential of an earlier run was not replaced: %v", err) - } else if again.Forget(); again.Token() != "" { - t.Errorf("a forgotten credential still holds its token") + if entries, err := os.ReadDir(stateDir); err != nil || len(entries) != 0 { + t.Errorf("the run wrote into the store's state directory: %v (%v)", entries, err) } if fmt.Sprint(result.RegistryTrusted) != "[192.0.2.10:5100 anchor.internal:5100]" { t.Errorf("the result says it trusted %v", result.RegistryTrusted) -- 2.54.0 From 1b01d065acc528f60d9153a214e176229cbc0a1e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 17:46:08 +0100 Subject: [PATCH 18/73] Pin mesh-sdk c54648e74346 and mesh-contracts ec9529022c44, the group's heads (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go.mod | 17 ++++++++++++----- go.sum | 32 ++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index c3e5c35..792853e 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,27 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104164016-c54648e74346 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 ) +require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 // indirect + require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104164000-ec9529022c44 + git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104164000-ec9529022c44 + git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104164000-ec9529022c44 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104164000-ec9529022c44 + git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104164000-ec9529022c44 + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index 7679a5d..c277f3f 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,27 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 h1:VH8ENC8vAMSJOLx0zalAJk0kwIw6TIZTvBR39ujUkR8= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= +git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104164000-ec9529022c44 h1:2OYf6Jib47B+kFEB+Drg5/H8ynwZ2NnOuZRVhOy5E+s= +git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104164000-ec9529022c44/go.mod h1:6xCtHETG2QMS+V/CGNQ7QXqv1+CG8F6/KvjjmZbcaYA= +git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104164000-ec9529022c44 h1:t7F7SX9sSzgaZm4zy2pfsm9vhFjy6i3aXbPvg4idRJA= +git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104164000-ec9529022c44/go.mod h1:FvgwjgF50VeP7Limr24zuInXxt/LN5rL5ULqyVBW46M= +git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104164000-ec9529022c44 h1:WhRw7fM6O6dRt81KVQYfRqNqtcU4m+uD+FVI/h/r25c= +git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104164000-ec9529022c44/go.mod h1:8dL18Gj/VOzNIBSJGlAdFHc9XonCcsZWm/JcjcpwwbU= +git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104164000-ec9529022c44 h1:nfJ6Hnkd8057qgUqlanX2FPBTUgIJiT+A6QMB742WY0= +git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104164000-ec9529022c44/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A= +git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104164000-ec9529022c44 h1:SmJixW3xfkaYuvHm9SbtYQqwzgxP6QeeSV49XCI+eII= +git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104164000-ec9529022c44/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104164016-c54648e74346 h1:nCGhsPT43CP1e7N7l2xEE0Fc9bJmW2v4fGdW+SnWrf8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104164016-c54648e74346/go.mod h1:QAPMiJBuHKg4cDeO3ib4aMNx+9HqPuvELZ2L2c+GYBo= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +35,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -- 2.54.0 From 4363c371c5c6f7f8bf5fcce5c1574e310376fc8b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:26:33 +0100 Subject: [PATCH 19/73] Pin mesh-sdk e5d89c5eedb5 and mesh-contracts 347a3053580a, the group's heads after review round 2 (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 792853e..9b36a73 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,21 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104164016-c54648e74346 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104172236-e5d89c5eedb5 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 ) -require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 // indirect +require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a // indirect require ( - git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104164000-ec9529022c44 - git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104164000-ec9529022c44 - git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104164000-ec9529022c44 - git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104164000-ec9529022c44 - git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104164000-ec9529022c44 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104172218-347a3053580a + git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104172218-347a3053580a + git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104172218-347a3053580a + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104172218-347a3053580a + git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect diff --git a/go.sum b/go.sum index c277f3f..d574301 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 h1:VH8ENC8vAMSJOLx0zalAJk0kwIw6TIZTvBR39ujUkR8= -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= -git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104164000-ec9529022c44 h1:2OYf6Jib47B+kFEB+Drg5/H8ynwZ2NnOuZRVhOy5E+s= -git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104164000-ec9529022c44/go.mod h1:6xCtHETG2QMS+V/CGNQ7QXqv1+CG8F6/KvjjmZbcaYA= -git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104164000-ec9529022c44 h1:t7F7SX9sSzgaZm4zy2pfsm9vhFjy6i3aXbPvg4idRJA= -git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104164000-ec9529022c44/go.mod h1:FvgwjgF50VeP7Limr24zuInXxt/LN5rL5ULqyVBW46M= -git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104164000-ec9529022c44 h1:WhRw7fM6O6dRt81KVQYfRqNqtcU4m+uD+FVI/h/r25c= -git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104164000-ec9529022c44/go.mod h1:8dL18Gj/VOzNIBSJGlAdFHc9XonCcsZWm/JcjcpwwbU= -git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104164000-ec9529022c44 h1:nfJ6Hnkd8057qgUqlanX2FPBTUgIJiT+A6QMB742WY0= -git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104164000-ec9529022c44/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A= -git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104164000-ec9529022c44 h1:SmJixW3xfkaYuvHm9SbtYQqwzgxP6QeeSV49XCI+eII= -git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104164000-ec9529022c44/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104164016-c54648e74346 h1:nCGhsPT43CP1e7N7l2xEE0Fc9bJmW2v4fGdW+SnWrf8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104164016-c54648e74346/go.mod h1:QAPMiJBuHKg4cDeO3ib4aMNx+9HqPuvELZ2L2c+GYBo= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a h1:E9ERPqR4uZXypudHGxv2/wNIOYcder9torVqJwJq/DI= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= +git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104172218-347a3053580a h1:+fvJK43ThUZ3HMNdalOMwbsTuZnRGb2c/scKvVj03ds= +git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104172218-347a3053580a/go.mod h1:Uaz0hwNaqgC438/6o+pJSQPIPRKY4khsbnkRpjuq9is= +git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104172218-347a3053580a h1:66enwP96ZpY5gLVQzDgFfTtWquIHCgEJaYuNdElZhsU= +git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104172218-347a3053580a/go.mod h1:Y5/MGgb1k7Ircs7oZF7xwas8lxfdWDPzDCdr2EJb3d4= +git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104172218-347a3053580a h1:oHonQ3sHq0pn3JYFv0FH8jgxf5MxKbbyBMp84BrwRhM= +git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104172218-347a3053580a/go.mod h1:8dL18Gj/VOzNIBSJGlAdFHc9XonCcsZWm/JcjcpwwbU= +git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104172218-347a3053580a h1:ZdYc+f3A8l2oDjN+LMngI8grXvT01Ud75/gPIAnuTko= +git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104172218-347a3053580a/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A= +git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a h1:UzJfgbyGlZJzsOFevu/c7stv1ccejrS4nAMfYytSd8s= +git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104172236-e5d89c5eedb5 h1:H50nHIZJ3CILBMpRfbKHJS6xv53NJcPlU7Wz0C3qsjE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104172236-e5d89c5eedb5/go.mod h1:2wHG7ntLCG8Tpm/SCfgp524a5emUJm1gAC4KrRYsU9s= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From 32c5df952cbf5519de7f6f606957a9e67b6d41df Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:47:39 +0100 Subject: [PATCH 20/73] Pin mesh-sdk 05b3e2e1b94f and mesh-contracts ae5266e77639, the group's final heads (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 9b36a73..1203a31 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,21 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104172236-e5d89c5eedb5 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 ) -require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a // indirect +require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect require ( - git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104172218-347a3053580a - git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104172218-347a3053580a - git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104172218-347a3053580a - git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104172218-347a3053580a - git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect diff --git a/go.sum b/go.sum index d574301..87c9bb8 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a h1:E9ERPqR4uZXypudHGxv2/wNIOYcder9torVqJwJq/DI= -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= -git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104172218-347a3053580a h1:+fvJK43ThUZ3HMNdalOMwbsTuZnRGb2c/scKvVj03ds= -git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104172218-347a3053580a/go.mod h1:Uaz0hwNaqgC438/6o+pJSQPIPRKY4khsbnkRpjuq9is= -git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104172218-347a3053580a h1:66enwP96ZpY5gLVQzDgFfTtWquIHCgEJaYuNdElZhsU= -git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104172218-347a3053580a/go.mod h1:Y5/MGgb1k7Ircs7oZF7xwas8lxfdWDPzDCdr2EJb3d4= -git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104172218-347a3053580a h1:oHonQ3sHq0pn3JYFv0FH8jgxf5MxKbbyBMp84BrwRhM= -git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104172218-347a3053580a/go.mod h1:8dL18Gj/VOzNIBSJGlAdFHc9XonCcsZWm/JcjcpwwbU= -git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104172218-347a3053580a h1:ZdYc+f3A8l2oDjN+LMngI8grXvT01Ud75/gPIAnuTko= -git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104172218-347a3053580a/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A= -git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a h1:UzJfgbyGlZJzsOFevu/c7stv1ccejrS4nAMfYytSd8s= -git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104172236-e5d89c5eedb5 h1:H50nHIZJ3CILBMpRfbKHJS6xv53NJcPlU7Wz0C3qsjE= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104172236-e5d89c5eedb5/go.mod h1:2wHG7ntLCG8Tpm/SCfgp524a5emUJm1gAC4KrRYsU9s= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= +git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 h1:3FTxvHrv3Hv+ZUbp/ezTrndqdgiS+Z6EUG2XGYJNJr8= +git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/V/1wfyQwjE9VbPLsFtkf6uX+SHKUXX34s5JscDuGAs= +git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w= +git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0= +git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639 h1:LyN9de63PwLy2VL0RZWjSsoRf0kSX5zisMpeDvKl42k= +git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639/go.mod h1:8dL18Gj/VOzNIBSJGlAdFHc9XonCcsZWm/JcjcpwwbU= +git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4= +git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A= +git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM= +git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From 66e89b71a2bceb92e7d5bb8699530f091ab7c841 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 19:56:18 +0100 Subject: [PATCH 21/73] Offer the catalogue forge's provisions in the toolchain test's stand-in (issue 1966) The group's mesh-build-go requires go-package-registry, which the forge now provides; the stand-in listed the forge's offers by hand and missed it, so the test failed against the group's catalogue. It now takes them from the catalogue's gitea manifest, so a registry the forge gains needs no edit. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- internal/compose/resolution/toolchain_manifests_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/compose/resolution/toolchain_manifests_test.go b/internal/compose/resolution/toolchain_manifests_test.go index 43c226d..c6d6725 100644 --- a/internal/compose/resolution/toolchain_manifests_test.go +++ b/internal/compose/resolution/toolchain_manifests_test.go @@ -18,8 +18,14 @@ func builderNode() mcat.Node { func toolchainShelf(t *testing.T) map[string]mcat.Manifest { t.Helper() + // The stand-in offers by name what the catalogue's forge offers, so a registry the forge gains is offered + // here too and never has to be added by hand, plus the two it stands in for that the forge does not offer. + offers := []mcat.Offer{{Name: "artifact-store"}, {Name: "node-tools"}} + for _, o := range catalogueManifest(t, "gitea").Provides { + offers = append(offers, mcat.Offer{Name: o.Name}) + } m := map[string]mcat.Manifest{ - "forge": {Module: "forge", Version: "1", Provides: []mcat.Offer{{Name: "forge-account"}, {Name: "npm-package-registry"}, {Name: "artifact-store"}, {Name: "node-tools"}}}, + "forge": {Module: "forge", Version: "1", Provides: offers}, } for _, name := range []string{"go", "nodejs", "mesh-build-go", "mesh-build-node", "dev-tools"} { m[name] = catalogueManifest(t, name) -- 2.54.0 From 6adaf44f6555707295919e61f5d98126e14e92f8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 20:56:43 +0100 Subject: [PATCH 22/73] =?UTF-8?q?Composer:=20the=20store's=20address=20is?= =?UTF-8?q?=20the=20image=20seat=20holder's=20listen=20open=20to=20the=20m?= =?UTF-8?q?esh=20(ADR=200632=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provision artifact-store retires, so the store is found by the holder of container-image-registry and the listen it opens to the mesh, on the port its node put it on; every name of the store, oci-artifact-registry's too, answers that address. The engine states form:seat-reach with its contract. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/bootstrap/registry.go | 6 +- internal/compose/composed_test.go | 3 +- internal/compose/frombus/answer_rows_test.go | 10 ++- internal/compose/frombus/frombus.go | 24 +++-- internal/compose/frombus/frombus_test.go | 4 +- .../frombus/frombustest/frombustest.go | 4 +- internal/compose/frombus/held_rows_test.go | 4 +- internal/compose/rendering.go | 87 +++++++++++++++---- internal/compose/rendering_test.go | 38 +++++++- internal/compose/resolution/artifacts.go | 6 +- internal/compose/resolution/seat_into.go | 15 +++- internal/compose/resolution/seat_into_test.go | 11 ++- internal/link/have_test.go | 4 + 13 files changed, 167 insertions(+), 49 deletions(-) diff --git a/internal/bootstrap/registry.go b/internal/bootstrap/registry.go index 966d612..8bc3803 100644 --- a/internal/bootstrap/registry.go +++ b/internal/bootstrap/registry.go @@ -9,10 +9,10 @@ import ( "time" ) -// RegistryModule is the module that provides the mesh's artifact store. +// RegistryModule is the module that holds the mesh's artifact store. // -// **Named for the software, not the job.** The job is `artifact-store`, which is the provision this -// module offers; the software is Distribution, the OCI reference implementation. Calling the module +// **Named for the software, not the job.** The job is the artifact store, which this module holds as +// the seat container-image-registry (novox/hq ADR 0632 §1); the software is Distribution, the OCI reference implementation. Calling the module // `registry` named neither — and promised that any registry could sit there, which is the false // genericity the naming rule forbids (novox/hq ADR 0040). const RegistryModule = "distribution" diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index 59cb564..c66878b 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -109,7 +109,8 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'), Digest: dg('a'), Language: "go", Binary: "node-tools"}}} store := catalogue.Manifest{Module: "artifacts", Version: "1", - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}} lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}}, Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 5954cfc..05a7fe9 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -606,13 +606,15 @@ func otherNodeNotResolvedIsNamed(t *testing.T) { // "reaches no artifact store", and nothing said why). func noStoreReachedSaysWhy(t *testing.T) { in := compose.Input{Catalogue: map[string]catalogue.Manifest{ - "distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, - Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}} - if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" { + "distribution": {Module: "distribution", Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}}, + // A module serving the retired provision artifact-store is no store (ADR 0632 §1). + "old-store": {Module: "old-store", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, + Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita", "old-store"}}, {Name: "novox", Assigned: []string{"distribution"}}}} + if got := storeSought(in); got != "the composition holds g14, novox; container-image-registry is held by distribution on novox, none of it on the private network with a listen open to the mesh" { t.Errorf("said %q", got) } in.Nodes = in.Nodes[:1] - if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" { + if got := storeSought(in); got != "the composition holds g14; no module placed there holds container-image-registry" { t.Errorf("said %q", got) } } diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 49292c5..f4c1d20 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -1136,16 +1136,14 @@ func sortedKeys[V any](m map[string]V) []string { } // storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and -// where each module serving the artifact store is placed, so the refusal names why (issue 1126). +// where each module claiming the store's seat is placed, so the refusal names why (issue 1126, ADR 0632 §1). func storeSought(in compose.Input) string { var nodes, where []string for _, n := range in.Nodes { nodes = append(nodes, n.Name) for _, a := range n.Assigned { - if m, ok := in.Catalogue[a]; ok { - if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves { - where = append(where, a+" on "+n.Name) - } + if m, ok := in.Catalogue[a]; ok && claimsSeat(m, compose.StoreSeat) { + where = append(where, a+" on "+n.Name) } } } @@ -1153,10 +1151,20 @@ func storeSought(in compose.Input) string { sort.Strings(where) said := "the composition holds " + strings.Join(nodes, ", ") if len(where) == 0 { - return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision + return said + "; no module placed there holds " + compose.StoreSeat } - return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") + - ", none of it on the private network with a port" + return said + "; " + compose.StoreSeat + " is held by " + strings.Join(where, ", ") + + ", none of it on the private network with a listen open to the mesh" +} + +// claimsSeat says whether m claims seat at the mesh's scope. +func claimsSeat(m catalogue.Manifest, seat string) bool { + for _, cl := range m.Claims { + if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat && cl.At() == catalogue.ScopeMesh { + return true + } + } + return false } // keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil. diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 7e030c1..3f4e889 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -577,8 +577,8 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"serves":{"artifact-store":{"port":5000}}}`) + store := []byte(`{"module":"store","version":"1",` + + `"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`) texts := map[string]string{} var packed bytes.Buffer zw := gzip.NewWriter(&packed) diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go index a627e30..5b00ed6 100644 --- a/internal/compose/frombus/frombustest/frombustest.go +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -119,8 +119,8 @@ func Provisioned() (*Bus, error) { if err != nil { return nil, err } - store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"serves":{"artifact-store":{"port":5000}}}`) + store := []byte(`{"module":"store","version":"1",` + + `"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`) postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` + `"serves":{"postgres-database":{"port":5432}}}`) diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index ef66cee..46cd176 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -119,7 +119,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) running := []delivery.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{ @@ -215,7 +215,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` } lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` for _, row := range []struct { name string diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index 770db5e..f6c0bb4 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -167,10 +167,10 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). if store != "" { - // Both names the store is reached through answer the one address (ENG-3), each with its holder from the holdings on - // record, written beside every image routed through it (design 51, ENG-10). + // Every name the store is reached through answers the one address (ENG-3, ADR 0632 §1), each with its holder from the + // holdings on record, written beside every image routed through it (design 51, ENG-10). r.SeatReach, r.SeatHolder = map[string]string{}, map[string]string{} - for _, seat := range []string{"mesh-artifact-store", "container-image-registry"} { + for _, seat := range resolution.StoreSeats { r.SeatReach[seat] = store for _, h := range c.in.Holdings { if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat && h.Scope == catalogue.ScopeMesh && h.Module != "" { @@ -326,18 +326,67 @@ func (c *composer) namesInTheMesh() map[string]string { return out } -// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on. -func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { - providers := map[string]catalogue.Manifest{} - for name, m := range c.in.Catalogue { - if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers { - providers[name] = m +// StoreSeat is the seat whose holder is the artifact store (novox/hq ADR 0632 §1): the store's address is that holder's +// listen open to the mesh, on the machine placement puts it on. The provision artifact-store it was found by before is +// retired; no module serves it. +const StoreSeat = "container-image-registry" + +// storeHolders are the modules holding the store's seat: the holdings on record, and only where none is on record every +// module of the catalogue claiming it at the mesh's scope, as a seat holder's reach is read (replicatedHolders). +func (c *composer) storeHolders() map[string]catalogue.Manifest { + out := map[string]catalogue.Manifest{} + for _, h := range c.in.Holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == StoreSeat && h.Scope == catalogue.ScopeMesh && h.Module != "" { + if m, known := c.in.Catalogue[h.Module]; known { + out[h.Module] = m + } } } - if len(providers) == 0 { + if len(out) > 0 { + return out + } + for name, m := range c.in.Catalogue { + for _, cl := range m.Claims { + if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == StoreSeat && cl.At() == catalogue.ScopeMesh { + out[name] = m + } + } + } + return out +} + +// storeListen is the store holder's listen every machine pulls from: its one listen open to the mesh. A holder opening +// none, or more than one, says no address, by name: which of two ports is the store is no guess the composer makes. +func storeListen(m catalogue.Manifest) (catalogue.Listening, error) { + var open []catalogue.Listening + for _, l := range m.Listens { + if l.From == catalogue.FromMesh && l.At() == "tcp" { + open = append(open, l) + } + } + switch len(open) { + case 1: + return open[0], nil + case 0: + return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens no tcp listen to the mesh: the artifact store has no "+ + "address (novox/hq ADR 0632 §1)", m.Module, StoreSeat) + } + var said []string + for _, l := range open { + said = append(said, fmt.Sprintf("%s (%d)", l.Name, l.Port)) + } + return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens %s to the mesh: the artifact store's address is its one "+ + "listen open to the mesh (novox/hq ADR 0632 §1)", m.Module, StoreSeat, strings.Join(said, ", ")) +} + +// artifactStoreOnNetwork is the machine on this network that holds the store's seat, and the port that machine put the +// holder's listen on (novox/hq ADR 0632 §1). +func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { + holders := c.storeHolders() + if len(holders) == 0 { return "", "", false, nil } - // In a stated order, so two machines offering it would always answer the same one. + // In a stated order, so two machines holding it would always answer the same one. machines := make([]string, 0, len(on)) for machine := range on { machines = append(machines, machine) @@ -349,17 +398,23 @@ func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err) } for _, a := range n.Assigned { - m, offers := providers[a] - if !offers { + m, holds := holders[a] + if !holds { continue } - serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision) + l, err := storeListen(m) + if err != nil { + return "", "", false, err + } + ports, _, err := c.portsGivenOn(machine, m) if err != nil { return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err) } - if p, ok := serves["port"]; ok { - return machine, fmt.Sprintf("%v", p), true, nil + at := l.Port + if given, ok := ports[l.Port]; ok { + at = given } + return machine, fmt.Sprintf("%d", at), true, nil } } return "", "", false, nil diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go index f8d1448..0489a5e 100644 --- a/internal/compose/rendering_test.go +++ b/internal/compose/rendering_test.go @@ -254,7 +254,8 @@ func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) { func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) { in := aMesh(t) in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1", - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}} anchor := nodeOf(t, &in, "anchor") anchor.Assigned = append(anchor.Assigned, "artifacts") if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" { @@ -290,6 +291,41 @@ func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *test } } +// The store's address is the image seat's holder's listen open to the mesh, on the port its node put it on, and every +// name of the store answers it (novox/hq ADR 0632 §1): a module serving the retired provision artifact-store is no store, +// and a holder opening two listens to the mesh says which is the store by no guess. +func TestTheStoreIsFoundByTheImageSeatsHolderAndItsListen(t *testing.T) { + in := aMesh(t) + in.Catalogue["old"] = catalogue.Manifest{Module: "old", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "old") + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" { + t.Errorf("a module serving artifact-store is reached as the store at %q", got) + } + in.Catalogue["registry"] = catalogue.Manifest{Module: "registry", Version: "1", + Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Name: "local", Port: 5001, From: catalogue.FromMachine}, + {Name: "registry", Port: 5000, From: catalogue.FromMesh}}} + anchor.Assigned = append(anchor.Assigned, "registry") + anchor.Ports = map[string]map[int]int{"registry": {5000: 5200}} + r := placed(t, in, "laptop").Rendering + if r.ArtifactStore != "anchor.internal:5200" { + t.Errorf("laptop reaches the store at %q, want the holder's listen open to the mesh on anchor's port", r.ArtifactStore) + } + for _, seat := range []string{"mesh-artifact-store", "container-image-registry", "oci-artifact-registry"} { + if r.SeatReach[seat] != "anchor.internal:5200" { + t.Errorf("${seat:%s:reach} is %q", seat, r.SeatReach[seat]) + } + } + m := in.Catalogue["registry"] + m.Listens = append(m.Listens, catalogue.Listening{Name: "other", Port: 5300, From: catalogue.FromMesh}) + in.Catalogue["registry"] = m + if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "opens registry (5000), other (5300) to the mesh") { + t.Errorf("a holder opening two listens to the mesh: %v", err) + } +} + // A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on // the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name. func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) { diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go index be3ce85..67aad4b 100644 --- a/internal/compose/resolution/artifacts.go +++ b/internal/compose/resolution/artifacts.go @@ -36,9 +36,9 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) if with.ArtifactStore == "" { return fmt.Errorf( "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+ - "artifact store on its network to fetch it from — nothing assigned offers "+ - "%s, or the machine offering it is not on the private network", - module, resource["id"], written, mcat.ArtifactStoreProvision) + "artifact store on its network to fetch it from — nothing assigned holds the seat "+ + "container-image-registry, or the machine holding it is not on the private network", + module, resource["id"], written) } resource[key] = mcat.Routed(written, with.ArtifactStore) imageSeatInto(resource, key, written, with) diff --git a/internal/compose/resolution/seat_into.go b/internal/compose/resolution/seat_into.go index c78ec5c..53e26a6 100644 --- a/internal/compose/resolution/seat_into.go +++ b/internal/compose/resolution/seat_into.go @@ -34,9 +34,18 @@ var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`) // reachedSeats is every seat ${seat:…:reach} answers for. // -// Both names of the store are answered with one address (ADR 0605, issue 1966 ENG-3): the seat it was held through and the -// seat the registries' holder serves the same port as. -var reachedSeats = map[string]bool{"mesh-artifact-store": true, "container-image-registry": true} +// Every name of the store is answered with one address (ADR 0605, issue 1966 ENG-3, ADR 0632 §1): the seat it was held +// through and the two registry seats its holder serves on the same port, the image seat's holder's listen open to the mesh. +var reachedSeats = map[string]bool{} + +// StoreSeats are the seats the artifact store is reached through, each answered with the one address. +var StoreSeats = []string{"mesh-artifact-store", "container-image-registry", "oci-artifact-registry"} + +func init() { + for _, s := range StoreSeats { + reachedSeats[s] = true + } +} // seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's // holder — in a file's content, and in a value of a container's or a process's environment. The diff --git a/internal/compose/resolution/seat_into_test.go b/internal/compose/resolution/seat_into_test.go index ddb7246..41e3a08 100644 --- a/internal/compose/resolution/seat_into_test.go +++ b/internal/compose/resolution/seat_into_test.go @@ -111,15 +111,18 @@ func TestASeatsReachIsWhereThisMachineReachesItsHolder(t *testing.T) { t.Fatalf("an environment value was filled with %q", got) } - // And the seat the registries' holder serves the same port through answers the same address (ENG-3). + // And the seats the registries' holder serves the same port through answer the same address (ENG-3, ADR 0632 §1). both := map[string]any{"type": "process", "name": "p", "env": map[string]any{ - "OLD": "${seat:mesh-artifact-store:reach}", "NEW": "${seat:container-image-registry:reach}"}} + "OLD": "${seat:mesh-artifact-store:reach}", "NEW": "${seat:container-image-registry:reach}", + "OCI": "${seat:oci-artifact-registry:reach}"}} with.SeatReach["container-image-registry"] = "anchor.internal:5100" + with.SeatReach["oci-artifact-registry"] = "anchor.internal:5100" if err := seatInto(both, "x", with); err != nil { t.Fatal(err) } - if env := both["env"].(map[string]any); env["OLD"] != "anchor.internal:5100" || env["NEW"] != "anchor.internal:5100" { - t.Fatalf("the two names of the store answer %v", env) + if env := both["env"].(map[string]any); env["OLD"] != "anchor.internal:5100" || env["NEW"] != "anchor.internal:5100" || + env["OCI"] != "anchor.internal:5100" { + t.Fatalf("the three names of the store answer %v", env) } } diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 93916f9..47a3061 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -286,6 +286,10 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { if !delivery.SameNodeRecord(*got.Record, wantRecord) { t.Errorf("record = %+v, want %+v", *got.Record, wantRecord) } + // Among them, that it fills a seat's reach from the seat holder's listen (ADR 0632 §1). + if !slices.Contains(got.Record.ManifestReads, catalogue.ReadForm+catalogue.ReferenceFormSeatReach) { + t.Errorf("the engine does not state it reads a seat's reach from its holder: %v", got.Record.ManifestReads) + } at := l.kv["n1.node"].rev q.SayFacts(context.Background(), rep) if l.kv["n1.node"].rev != at { -- 2.54.0 From e5d9753368affbdef18002703c6958a11fbe61d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 21:10:55 +0100 Subject: [PATCH 23/73] =?UTF-8?q?Resolution=20test:=20the=20runtime's=20tr?= =?UTF-8?q?ust=20is=20filled=20from=20every=20name=20of=20the=20store=20(A?= =?UTF-8?q?DR=200632=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/compose/resolution/generated_collision_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/compose/resolution/generated_collision_test.go b/internal/compose/resolution/generated_collision_test.go index cd0c17c..58d46d3 100644 --- a/internal/compose/resolution/generated_collision_test.go +++ b/internal/compose/resolution/generated_collision_test.go @@ -56,7 +56,9 @@ func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) { docker := catalogueManifest(t, "docker") r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{docker}} out, err := Declaration(r, mcat.Rendering{ - SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"}, + // Every name of the store answers the one address, as the composer fills them (ADR 0632 §1). + SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100", + "container-image-registry": "anchor.internal:5100", "oci-artifact-registry": "anchor.internal:5100"}, }) if err != nil { t.Fatal(err) -- 2.54.0 From 570b66230cfe3dcf46b4838a6f1c0d2d67c05939 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 21:29:12 +0100 Subject: [PATCH 24/73] =?UTF-8?q?Composer:=20find=20the=20store=20by=20the?= =?UTF-8?q?=20holder=20of=20any=20of=20its=20names,=20the=20old=20seat's?= =?UTF-8?q?=20too=20(ADR=200632=20=C2=A72;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the indexer holds distribution's new manifest, every node reads main's, which claims only mesh-artifact-store: a new engine must find the store there. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/compose/frombus/frombus.go | 8 ++++---- internal/compose/rendering.go | 14 +++++++++++--- internal/compose/rendering_test.go | 30 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index f4c1d20..a7e887c 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -1142,7 +1142,7 @@ func storeSought(in compose.Input) string { for _, n := range in.Nodes { nodes = append(nodes, n.Name) for _, a := range n.Assigned { - if m, ok := in.Catalogue[a]; ok && claimsSeat(m, compose.StoreSeat) { + if m, ok := in.Catalogue[a]; ok && claimsStore(m) { where = append(where, a+" on "+n.Name) } } @@ -1157,10 +1157,10 @@ func storeSought(in compose.Input) string { ", none of it on the private network with a listen open to the mesh" } -// claimsSeat says whether m claims seat at the mesh's scope. -func claimsSeat(m catalogue.Manifest, seat string) bool { +// claimsStore says whether m claims a name of the store (resolution.StoreSeats) at the mesh's scope. +func claimsStore(m catalogue.Manifest) bool { for _, cl := range m.Claims { - if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat && cl.At() == catalogue.ScopeMesh { + if cs, ok := catalogue.SeatNamed(cl.Name); ok && slices.Contains(resolution.StoreSeats, cs.Name) && cl.At() == catalogue.ScopeMesh { return true } } diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index f6c0bb4..7d06652 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -3,6 +3,7 @@ package compose import ( "errors" "fmt" + "slices" "sort" "strings" @@ -332,11 +333,18 @@ func (c *composer) namesInTheMesh() map[string]string { const StoreSeat = "container-image-registry" // storeHolders are the modules holding the store's seat: the holdings on record, and only where none is on record every -// module of the catalogue claiming it at the mesh's scope, as a seat holder's reach is read (replicatedHolders). +// module of the catalogue claiming it at the mesh's scope, as a seat holder's reach is read (replicatedHolders). Any name +// of the store counts (resolution.StoreSeats): until every counted engine states the new reading, the manifest every +// node reads is the holder's old one, which claims only mesh-artifact-store (ADR 0632 §2), and an engine that finds the +// store by the seat must find it there too. func (c *composer) storeHolders() map[string]catalogue.Manifest { + isStore := func(claim string) bool { + hs, ok := catalogue.SeatNamed(claim) + return ok && slices.Contains(resolution.StoreSeats, hs.Name) + } out := map[string]catalogue.Manifest{} for _, h := range c.in.Holdings { - if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == StoreSeat && h.Scope == catalogue.ScopeMesh && h.Module != "" { + if isStore(h.Claim) && h.Scope == catalogue.ScopeMesh && h.Module != "" { if m, known := c.in.Catalogue[h.Module]; known { out[h.Module] = m } @@ -347,7 +355,7 @@ func (c *composer) storeHolders() map[string]catalogue.Manifest { } for name, m := range c.in.Catalogue { for _, cl := range m.Claims { - if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == StoreSeat && cl.At() == catalogue.ScopeMesh { + if isStore(cl.Name) && cl.At() == catalogue.ScopeMesh { out[name] = m } } diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go index 0489a5e..8c50b5b 100644 --- a/internal/compose/rendering_test.go +++ b/internal/compose/rendering_test.go @@ -318,6 +318,36 @@ func TestTheStoreIsFoundByTheImageSeatsHolderAndItsListen(t *testing.T) { t.Errorf("${seat:%s:reach} is %q", seat, r.SeatReach[seat]) } } + // main's distribution, the manifest every node reads while the indexer holds the new one (ADR 0632 §2): it claims + // only mesh-artifact-store and serves the retired provision; the engine finds the store by that claim and its listen. + old := in + old.Catalogue = map[string]catalogue.Manifest{} + for k, v := range in.Catalogue { + if k != "registry" { + old.Catalogue[k] = v + } + } + old.Catalogue["distribution"] = catalogue.Manifest{Module: "distribution", Version: "2", + Provides: []catalogue.Offer{{Name: catalogue.ArtifactStoreProvision, Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-artifact-store", Scope: catalogue.ScopeMesh}}, + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}, + Listens: []catalogue.Listening{{Name: "registry", Port: 5000, Protocol: "tcp", From: catalogue.FromMesh}}} + old.Nodes = append([]Node(nil), in.Nodes...) + for i := range old.Nodes { + if old.Nodes[i].Name == "anchor" { + var kept []string + for _, a := range old.Nodes[i].Assigned { + if a != "registry" && a != "old" { + kept = append(kept, a) + } + } + old.Nodes[i].Assigned = append(kept, "distribution") + old.Nodes[i].Ports = map[string]map[int]int{"distribution": {5000: 5400}} + } + } + if got := placed(t, old, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5400" { + t.Errorf("main's distribution, claiming only mesh-artifact-store, is reached at %q, want anchor.internal:5400", got) + } m := in.Catalogue["registry"] m.Listens = append(m.Listens, catalogue.Listening{Name: "other", Port: 5300, From: catalogue.FromMesh}) in.Catalogue["registry"] = m -- 2.54.0 From 7623279efe83b487be802554f335f0ddff33070e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:14:46 +0100 Subject: [PATCH 25/73] =?UTF-8?q?Node-engine:=20no=20compiled=20seat=20lis?= =?UTF-8?q?t;=20the=20seat's=20name=20and=20verbs=20come=20from=20its=20co?= =?UTF-8?q?ntract,=20the=20tests=20give=20catalogue=20their=20seats=20(H-7?= =?UTF-8?q?,=20ADR=200635=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frombustest/seats_for_tests_test.go | 10 ++++++++++ .../compose/frombus/seats_for_tests_test.go | 10 ++++++++++ internal/compose/resolution/files_test.go | 11 ++++++----- internal/compose/resolution/seats.go | 10 ---------- .../resolution/seats_for_tests_test.go | 10 ++++++++++ internal/compose/resolution/seats_test.go | 19 ------------------- internal/compose/seats_for_tests_test.go | 10 ++++++++++ internal/composer/seats_for_tests_test.go | 10 ++++++++++ internal/link/engineseat.go | 5 +++-- 9 files changed, 59 insertions(+), 36 deletions(-) create mode 100644 internal/compose/frombus/frombustest/seats_for_tests_test.go create mode 100644 internal/compose/frombus/seats_for_tests_test.go create mode 100644 internal/compose/resolution/seats_for_tests_test.go create mode 100644 internal/compose/seats_for_tests_test.go create mode 100644 internal/composer/seats_for_tests_test.go diff --git a/internal/compose/frombus/frombustest/seats_for_tests_test.go b/internal/compose/frombus/frombustest/seats_for_tests_test.go new file mode 100644 index 0000000..2700f7c --- /dev/null +++ b/internal/compose/frombus/frombustest/seats_for_tests_test.go @@ -0,0 +1,10 @@ +package frombustest + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus +// (novox/hq ADR 0635 §1); catalogue compiles none. +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/internal/compose/frombus/seats_for_tests_test.go b/internal/compose/frombus/seats_for_tests_test.go new file mode 100644 index 0000000..16c5987 --- /dev/null +++ b/internal/compose/frombus/seats_for_tests_test.go @@ -0,0 +1,10 @@ +package frombus + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus +// (novox/hq ADR 0635 §1); catalogue compiles none. +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/internal/compose/resolution/files_test.go b/internal/compose/resolution/files_test.go index 4d8ed14..625f40d 100644 --- a/internal/compose/resolution/files_test.go +++ b/internal/compose/resolution/files_test.go @@ -42,12 +42,13 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) { Resources: []map[string]any{{"id": "stub", "type": "file", "path": mcat.ResolverFile}}} uplink := mcat.Manifest{Module: "networkmanager", Version: "1", Claims: []mcat.Claim{{Name: "node-uplink"}}, Resources: []map[string]any{{"id": "resolv", "type": "file", "path": mcat.ResolverFile}}} - compiled := seats - t.Cleanup(func() { seats = compiled }) + compiled := mcat.Seats() + t.Cleanup(func() { mcat.UseSeats(compiled) }) for _, order := range []string{"compiled", "reversed"} { if order == "reversed" { - seats = append([]mcat.Seat(nil), compiled...) - slices.Reverse(seats) + reversed := append([]mcat.Seat(nil), compiled...) + slices.Reverse(reversed) + mcat.UseSeats(reversed) } both := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, uplink, resolver}} w, err := resolutionFilesWords(both, files, nil, mcat.Rendering{}) @@ -59,7 +60,7 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) { t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, mcat.ResolverFile, o[mcat.ResolverFile], mcat.ResolverSeat) } } - seats = compiled + mcat.UseSeats(compiled) // Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as // the controller tells them, and not the paths its running resources are at (a directory the mesh placed). carried := mcat.Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"}, diff --git a/internal/compose/resolution/seats.go b/internal/compose/resolution/seats.go index 1420499..8813719 100644 --- a/internal/compose/resolution/seats.go +++ b/internal/compose/resolution/seats.go @@ -4,16 +4,6 @@ import ( mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the fallback when it has none -// (novox/hq ADR 0122). It is mesh-contracts' generated list, as mesh-sdk compiles it, taken whole: the node-engine keeps no copy of -// a seat's verbs or events (design 26, ADR 0608 §5; issue 1966 ENG-3). -var defaultSeats = mcat.DefaultSeats() - -// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by -// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a -// change to data, not to this code. -var seats = defaultSeats - // configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its // text), and a limit that is a number; every other argument a string. func configInput() map[string]any { diff --git a/internal/compose/resolution/seats_for_tests_test.go b/internal/compose/resolution/seats_for_tests_test.go new file mode 100644 index 0000000..ef33ada --- /dev/null +++ b/internal/compose/resolution/seats_for_tests_test.go @@ -0,0 +1,10 @@ +package resolution + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus +// (novox/hq ADR 0635 §1); catalogue compiles none. +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/internal/compose/resolution/seats_test.go b/internal/compose/resolution/seats_test.go index bcbac2c..4a72341 100644 --- a/internal/compose/resolution/seats_test.go +++ b/internal/compose/resolution/seats_test.go @@ -1,7 +1,6 @@ package resolution import ( - "reflect" "strings" "testing" @@ -71,22 +70,4 @@ func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) } - // ADR 0604 section 1, F3: the seat table written here answers as the SDK's does for the catalog seat, its four - // verbs included, so no two copies of the table disagree. - want, ok := mcat.SeatNamed(mcat.CatalogSeat) - if !ok { - t.Fatal("the SDK holds no catalog seat") - } - var have *mcat.Seat - for i := range defaultSeats { - if defaultSeats[i].Name == mcat.CatalogSeat { - have = &defaultSeats[i] - } - } - if have == nil { - t.Fatal("the table here holds no catalog seat") - } - if !reflect.DeepEqual(*have, want) { - t.Errorf("the table here has %+v, the SDK's is %+v", *have, want) - } } diff --git a/internal/compose/seats_for_tests_test.go b/internal/compose/seats_for_tests_test.go new file mode 100644 index 0000000..c6afc26 --- /dev/null +++ b/internal/compose/seats_for_tests_test.go @@ -0,0 +1,10 @@ +package compose + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus +// (novox/hq ADR 0635 §1); catalogue compiles none. +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/internal/composer/seats_for_tests_test.go b/internal/composer/seats_for_tests_test.go new file mode 100644 index 0000000..5415dc6 --- /dev/null +++ b/internal/composer/seats_for_tests_test.go @@ -0,0 +1,10 @@ +package composer + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus +// (novox/hq ADR 0635 §1); catalogue compiles none. +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/internal/link/engineseat.go b/internal/link/engineseat.go index 5256348..4d47ace 100644 --- a/internal/link/engineseat.go +++ b/internal/link/engineseat.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -25,7 +26,7 @@ import ( // tool's (mesh-tools node-tools). One service, `node-engine`, answers discovery with each verb's seat, tool and node. // EngineSeat is the seat. -const EngineSeat = catalogue.EngineSeat +const EngineSeat = nodeengine.Seat // EngineToolSubject is where a verb of this node's seat is asked naming no caller. func EngineToolSubject(verb, node string) string { @@ -117,7 +118,7 @@ func (l *natsLink) ServeEngineSeat(handle EngineFunc) { return } apart := make(chan struct{}, apartAtOnce) - for _, v := range catalogue.EngineVerbs() { + for _, v := range catalogue.EngineVerbs(EngineSeat) { verb := v.Name answer := EngineAnswer(handle, verb, l.node, apart) meta := EngineEndpointMetadata(v, l.node) -- 2.54.0 From 66fe7c8a0ded2c39f27fb2fb26ffd04e3414950e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:16:04 +0100 Subject: [PATCH 26/73] =?UTF-8?q?Node-engine:=20the=20finding=20of=20a=20s?= =?UTF-8?q?eat=20list=20unread=20for=20five=20minutes,=20written=20once=20?= =?UTF-8?q?and=20cleared=20at=20the=20first=20full=20read=20(H-7,=20ADR=20?= =?UTF-8?q?0636=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/seatwait/seatwait.go | 118 +++++++++++++++++++++++ internal/seatwait/seatwait_test.go | 149 +++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 internal/seatwait/seatwait.go create mode 100644 internal/seatwait/seatwait_test.go diff --git a/internal/seatwait/seatwait.go b/internal/seatwait/seatwait.go new file mode 100644 index 0000000..9c74565 --- /dev/null +++ b/internal/seatwait/seatwait.go @@ -0,0 +1,118 @@ +// Package seatwait is the node-engine's word that its reader of the seat list has waited (novox/hq ADR 0636 §1): a node +// whose reader holds only the bootstrapped definitions for five minutes writes one finding in the engine's own state +// `findings`, which the condition keeper raises as a condition and clears at the first full read (ADR 0564 §2). +// +// Event-driven: the reader says each change (Changed), and the one bound is a timer started when the wait begins and +// stopped when it ends, never a loop. A timer that fires looks at the reader again before it writes, so a read that +// ended the wait without a change of the held set (the seat answered the list the reader already held) writes nothing. +package seatwait + +import ( + "fmt" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// Bound is how long the reader may wait before the node says it (ADR 0636 §1). +const Bound = 5 * time.Minute + +// Kind is the finding's kind; its id is the node's. +const Kind = "seat-list-unread" + +// Reader is what the Waiter asks of the seat list's reader. +type Reader interface { + Status() seatlist.Status +} + +// Timer is a started timer that can be stopped. +type Timer interface{ Stop() bool } + +// Clock gives the time and starts a timer; the tests give their own, so nothing sleeps. +type Clock interface { + Now() time.Time + AfterFunc(d time.Duration, f func()) Timer +} + +// Waiter writes and clears the node's finding. +type Waiter struct { + Node string + Observer string + Reader Reader + Findings views.FindingWriter + Clock Clock + // Say tells the node's journal; nil says nothing. + Say func(string) + + mu sync.Mutex + timer Timer + since time.Time + written *views.Finding +} + +func waiting(st seatlist.Status) bool { return !st.Heard || len(st.Waiting) > 0 } + +// Changed is told when the reader's held set or its status moved: it starts the bound when the reader waits and has no +// bound running, stops it and clears the finding when the reader has read the whole list. +func (w *Waiter) Changed() { + st := w.Reader.Status() + w.mu.Lock() + defer w.mu.Unlock() + if !waiting(st) { + if w.timer != nil { + w.timer.Stop() + w.timer = nil + } + w.since = time.Time{} + if w.written != nil { + if err := w.Findings.Delete(*w.written); err != nil { + w.say(fmt.Sprintf("the node's finding that its seat list is unread cannot be cleared: %v", err)) + return + } + w.written = nil + } + return + } + if w.timer != nil || w.written != nil { + return + } + w.since = w.Clock.Now() + w.timer = w.Clock.AfterFunc(Bound, w.expired) +} + +// Waiting says whether the bound is running or the finding stands, and since when. +func (w *Waiter) Waiting() (bool, time.Time) { + w.mu.Lock() + defer w.mu.Unlock() + return w.timer != nil || w.written != nil, w.since +} + +func (w *Waiter) expired() { + st := w.Reader.Status() + w.mu.Lock() + defer w.mu.Unlock() + w.timer = nil + if !waiting(st) { + w.since = time.Time{} + return + } + f := views.Finding{Observer: w.Observer, Kind: Kind, ID: w.Node, + Summary: fmt.Sprintf("%s has read no complete seat list since %s: %s", w.Node, w.since.UTC().Format(time.RFC3339), st.Health()), + Headline: fmt.Sprintf("%s waits for the seat list", w.Node), + Explanation: "The node holds only the definitions it starts with, so it keeps running what it runs and starts, composes and grants nothing new. It reads the list again at the next change the seat mesh-catalog says, or when asked to.", + At: w.Clock.Now()} + if err := w.Findings.Put(f); err != nil { + w.say(fmt.Sprintf("the node's finding that its seat list is unread cannot be written: %v", err)) + return + } + w.written = &f + w.say(f.Summary) +} + +func (w *Waiter) say(s string) { + if w.Say != nil { + w.Say(s) + } +} diff --git a/internal/seatwait/seatwait_test.go b/internal/seatwait/seatwait_test.go new file mode 100644 index 0000000..c04db1a --- /dev/null +++ b/internal/seatwait/seatwait_test.go @@ -0,0 +1,149 @@ +package seatwait + +import ( + "errors" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/views" +) + +type fakeReader struct{ st seatlist.Status } + +func (f *fakeReader) Status() seatlist.Status { return f.st } + +type fakeTimer struct { + c *fakeClock + due time.Time + f func() + stopped bool +} + +func (t *fakeTimer) Stop() bool { was := !t.stopped; t.stopped = true; return was } + +type fakeClock struct { + now time.Time + timers []*fakeTimer +} + +func (c *fakeClock) Now() time.Time { return c.now } +func (c *fakeClock) AfterFunc(d time.Duration, f func()) Timer { + t := &fakeTimer{c: c, due: c.now.Add(d), f: f} + c.timers = append(c.timers, t) + return t +} + +// advance moves the clock and fires the timers that fell due and were not stopped. +func (c *fakeClock) advance(d time.Duration) { + c.now = c.now.Add(d) + for _, t := range c.timers { + if !t.stopped && !t.due.After(c.now) { + t.stopped = true + t.f() + } + } +} + +type fakeFindings struct { + put, deleted []views.Finding + err error +} + +func (f *fakeFindings) Put(x views.Finding) error { + if f.err != nil { + return f.err + } + f.put = append(f.put, x) + return nil +} +func (f *fakeFindings) Delete(x views.Finding) error { f.deleted = append(f.deleted, x); return nil } + +// ADR 0636 §1: a reader that waited five minutes makes the node write one finding, which the first full read clears. +func caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead(t *testing.T) { + reader := &fakeReader{st: seatlist.Status{Heard: false, LastError: "no answer"}} + clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)} + findings := &fakeFindings{} + var said []string + w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Findings: findings, Clock: clock, Say: func(s string) { said = append(said, s) }} + + w.Changed() + clock.advance(Bound - time.Second) + if len(findings.put) != 0 { + t.Fatalf("a finding was written before the bound: %+v", findings.put) + } + w.Changed() // another change while waiting starts no second bound + clock.advance(time.Second) + if len(findings.put) != 1 || findings.put[0].Kind != Kind || findings.put[0].ID != "ace" { + t.Fatalf("after five minutes the node wrote %+v, want one %s finding for ace", findings.put, Kind) + } + clock.advance(time.Hour) + if len(findings.put) != 1 { + t.Fatalf("the finding was written again: %d", len(findings.put)) + } + + reader.st = seatlist.Status{Heard: true} + w.Changed() + if len(findings.deleted) != 1 || findings.deleted[0].ID != "ace" { + t.Fatalf("the first full read left the finding: deleted %+v", findings.deleted) + } + if on, _ := w.Waiting(); on { + t.Error("the waiter still waits after the full read") + } +} + +// A read that ends the wait stops the bound; a timer that fires after the list was read writes nothing. +func caseAReadWithinTheBoundWritesNothing(t *testing.T) { + reader := &fakeReader{st: seatlist.Status{Heard: false}} + clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)} + findings := &fakeFindings{} + w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Findings: findings, Clock: clock} + w.Changed() + clock.advance(time.Minute) + reader.st = seatlist.Status{Heard: true} + w.Changed() + clock.advance(time.Hour) + if len(findings.put) != 0 || len(findings.deleted) != 0 { + t.Fatalf("a list read in time wrote %+v and cleared %+v", findings.put, findings.deleted) + } + + // The seat answered the list already held: no change was said, so the timer is the one to look again. + reader.st = seatlist.Status{Heard: false} + w.Changed() + reader.st = seatlist.Status{Heard: true} + clock.advance(Bound) + if len(findings.put) != 0 { + t.Fatalf("a timer that found the list read wrote %+v", findings.put) + } +} + +// A finding that cannot be written is said and tried again at the next change, not lost. +func caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain(t *testing.T) { + reader := &fakeReader{st: seatlist.Status{Heard: false}} + clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)} + findings := &fakeFindings{err: errors.New("bus down")} + var said []string + w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Findings: findings, Clock: clock, Say: func(s string) { said = append(said, s) }} + w.Changed() + clock.advance(Bound) + if len(said) != 1 || len(findings.put) != 0 { + t.Fatalf("said %v, wrote %v", said, findings.put) + } + findings.err = nil + w.Changed() + clock.advance(Bound) + if len(findings.put) != 1 { + t.Fatalf("the finding was not written at the next bound: %v", findings.put) + } +} + +// ADR 0636 §1, the node's finding of a seat list unread. +func TestTheSeatListWait(t *testing.T) { + for name, run := range map[string]func(*testing.T){ + "five minutes write one finding and the first full read clears it": caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead, + "a read within the bound writes nothing": caseAReadWithinTheBoundWritesNothing, + "a finding that cannot be written is said and tried again": caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain, + } { + t.Run(name, run) + } +} -- 2.54.0 From a222db8c69ebcb667c6a8507ab5ea29457b01732 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:17:28 +0100 Subject: [PATCH 27/73] =?UTF-8?q?Node-engine=20composer:=20a=20module=20ne?= =?UTF-8?q?w=20to=20the=20node=20is=20held=20until=20the=20seat=20list=20w?= =?UTF-8?q?as=20read,=20unless=20its=20seats=20are=20bootstrapped;=20the?= =?UTF-8?q?=20read=20hands=20it=20(H-7,=20ADR=200636=20=C2=A71;=20issue=20?= =?UTF-8?q?1966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/selfcompose/selfcompose.go | 109 +++++++++++++++++++++++ internal/selfcompose/selfcompose_test.go | 81 +++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 6607fc6..0bc5d21 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -102,6 +102,18 @@ type Env struct { ComposedState ComposedStore // Now is the clock what the composer waits on is timed by; nil is time.Now. Now func() time.Time + // SeatList is the node's reader of the seat list (novox/hq ADR 0636 §1): until the list was read, the composer hands + // nothing new of a module whose seats are not all bootstrapped, and keeps what runs of it. Nil gates nothing. + SeatList SeatGate + // SeatListChanged says the reader's held set changed: the node composes again, so what waited for the list is + // handed at the read and not on a timer. Nil hears nothing. + SeatListChanged <-chan struct{} +} + +// SeatGate is what the composer asks of the reader of the seat list: whether it may compose a module that claims, +// invokes or provides these seats (seatlist.Reader.MayAct). +type SeatGate interface { + MayAct(seats ...string) bool } // VaultSecretChanged is the vault's event that a secret changed: a name and its generation, never a value. @@ -439,6 +451,25 @@ func (c *Composer) Watch(ctx context.Context, _ string) (<-chan agent.Update, er } }() } + if c.env.SeatListChanged != nil { + go func() { + for { + select { + case <-ctx.Done(): + return + case _, ok := <-c.env.SeatListChanged: + if !ok { + return + } + select { + case merged <- tagged{in: -1, ok: true}: + case <-ctx.Done(): + return + } + } + } + }() + } out := make(chan agent.Update, 64) c.watching.Store(true) go func() { @@ -908,6 +939,9 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { done() return nil } + // A reader that has not read the seat list acts on nothing new (ADR 0636 §1): a module whose composition differs from + // what runs, and whose seats are not all bootstrapped, is held with what it runs. + held = c.holdUntilListed(ctx, r, compositions, held, running) // The boxes it runs, slot by slot: read before c.mu is taken, since the agent's lock is taken to read what runs. missed = c.missed.Load() for key, comp := range compositions { @@ -1731,3 +1765,78 @@ func mapValues(m map[string]delivery.Composed) []delivery.Composed { } return out } + +// WaitsForTheSeatList is why a module is held while the node has not read the seat list. +const WaitsForTheSeatList = "the node has not read the seat list, so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" + +// holdUntilListed takes out of compositions every module's composition that is new (no running composition has its +// digest) while the reader may not act on its seats, and names the module in held: it keeps running what it runs, and +// the pass after the list was read hands it. The node's own composition and a module whose composition runs already are +// left as they are. +func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions map[string]delivery.Composed, held map[string]string, running []delivery.Composed) map[string]string { + if c.env.SeatList == nil { + return held + } + ran := map[string]bool{} + for _, comp := range running { + if dig, err := delivery.ComposedDigestOf(comp); err == nil { + ran[comp.Module+"/"+dig] = true + } + } + for key, comp := range compositions { + if comp.Module == "" { + continue + } + if dig, err := delivery.ComposedDigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { + continue + } + if c.env.SeatList.MayAct(c.seatsOf(ctx, r, comp.Module)...) { + continue + } + delete(compositions, key) + if held == nil { + held = map[string]string{} + } + if _, already := held[comp.Module]; !already { + held[comp.Module] = WaitsForTheSeatList + } + } + return held +} + +// seatsOf is the seats a module claims, invokes or provides, from the manifest its module record names; none when it +// cannot be read, which the reader judges as a module no bootstrapped seat names. +func (c *Composer) seatsOf(ctx context.Context, r reader, module string) []string { + c.mu.Lock() + m, ok := c.modules[module] + c.mu.Unlock() + if !ok || m.Manifest == "" { + return nil + } + raw, err := r.Manifest(ctx, m.Manifest) + if err != nil { + return nil + } + var man struct { + Claims []struct { + Name string `json:"name"` + } `json:"claims"` + Invokes []string `json:"invokes"` + Provides []struct { + Name string `json:"name"` + } `json:"provides"` + } + if json.Unmarshal(raw, &man) != nil { + return nil + } + var seats []string + for _, cl := range man.Claims { + seats = append(seats, cl.Name) + } + for _, in := range man.Invokes { + if seat, _, ok := strings.Cut(strings.TrimPrefix(in, "seat:"), "."); ok && strings.HasPrefix(in, "seat:") { + seats = append(seats, seat) + } + } + return seats +} diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 170a0b3..542667a 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -1750,3 +1750,84 @@ func setFor[T any](t *testing.T, p *T, v T) { *p = v t.Cleanup(func() { *p = old }) } + +type gate struct{ open atomic.Bool } + +func (g *gate) MayAct(...string) bool { return g.open.Load() } + +// ADR 0636 §1: a node that has not read the seat list composes nothing new of a module whose seats are not bootstrapped, +// says it holds the module (so its have says why) and hands it at the read, which the reader says as an event; what +// already runs with the same composition is not held. +func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { + b := newFakeBus() + fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) + digest := delivery.ManifestDigest([]byte(lemurs)) + man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: lemurs}) + key, _ := delivery.ManifestsKey(digest) + b.kv[delivery.ManifestsBucket+"/"+key] = man + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + env := b.env() + g := &gate{} + changed := make(chan struct{}, 1) + env.SeatList, env.SeatListChanged = g, changed + var mu sync.Mutex + heldBy := map[string]string{} + env.Refused = func(_ string, held map[string]string) { mu.Lock(); heldBy = held; mu.Unlock() } + c := New(env) + out, err := c.Watch(ctx, "n1.>") + if err != nil { + t.Fatal(err) + } + place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) + have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) + nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) + b.put(t, delivery.PlacementBucket, "placement.n1", place) + b.put(t, delivery.HaveBucket, "n1.node", have) + b.put(t, delivery.ModulesBucket, "lemurs", mod) + b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) + b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) + for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + b.send(bucket, agent.Update{Caught: true}) + } + handed := map[string]bool{} + for { + u := next(t, out) + if u.Caught { + break + } + handed[u.Key] = true + } + mu.Lock() + why := heldBy["lemurs"] + mu.Unlock() + if why != WaitsForTheSeatList { + t.Errorf("lemurs is held with %q, want %q", why, WaitsForTheSeatList) + } + for k := range handed { + if strings.Contains(k, "lemurs") { + t.Errorf("the node handed %s before it read the seat list", k) + } + } + if !handed["n1.node"] { + t.Errorf("the node's own composition was held with the module: %v", handed) + } + // The reader reads the list and says so: the module is handed, with no timer. + g.open.Store(true) + changed <- struct{}{} + for { + u := next(t, out) + if strings.Contains(u.Key, "lemurs") && !u.Deleted { + break + } + } + mu.Lock() + still := heldBy["lemurs"] + mu.Unlock() + if still != "" { + t.Errorf("lemurs is still held after the read: %q", still) + } +} -- 2.54.0 From 6ebad21cbed662b44e9795403bce4ad76c840494 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:17:46 +0100 Subject: [PATCH 28/73] =?UTF-8?q?Node-engine:=20the=20seats=20are=20called?= =?UTF-8?q?=20by=20their=20names=20mesh-module-assigner=20and=20mesh-node-?= =?UTF-8?q?enroller=20(ADR=200634=20=C2=A79;=20H-5;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/enrol_seat.go | 4 ++-- cmd/mesh-host/main.go | 2 +- cmd/mesh-host/trust.go | 4 ++-- internal/compose/frombus/frombus.go | 4 ++-- internal/compose/resolution/placement_seat.go | 6 +++--- internal/link/enrol.go | 2 +- internal/link/enrol_seat.go | 8 ++++---- internal/link/enrol_seat_test.go | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cmd/mesh-host/enrol_seat.go b/cmd/mesh-host/enrol_seat.go index 94cb23b..41adf93 100644 --- a/cmd/mesh-host/enrol_seat.go +++ b/cmd/mesh-host/enrol_seat.go @@ -14,7 +14,7 @@ import ( "github.com/novox/mesh-host/internal/link" ) -// enrolThroughSeat joins this machine through the seat mesh-enrolment (novox/hq ADR 0587 §6, issue 774): it redeems the token +// enrolThroughSeat joins this machine through the seat mesh-node-enroller (novox/hq ADR 0587 §6, issue 774): it redeems the token // with the keys it made, verifies the vault's signed answer against the key the token carries before it keeps anything, keeps // its membership and its login, dials as the login node-logins names and confirms. A bus that does not serve the seat to this // token (link.ErrNoSeat) is said as the error: nothing was sent or kept. @@ -64,7 +64,7 @@ func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, n if err != nil { return err } - fmt.Printf("\nenrolled as %s through the seat mesh-enrolment, dialled as %s and confirmed\n", answer.Node, answer.Login) + fmt.Printf("\nenrolled as %s through the seat mesh-node-enroller, dialled as %s and confirmed\n", answer.Node, answer.Login) fmt.Printf(" identity %s\n", identityPath) fmt.Println(" nothing is applied until the placement of this node names a module") return nil diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 0d03f6b..0076dc4 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -819,7 +819,7 @@ func enrol(ctx context.Context, opts options) error { detected := profile.Detect(ctx, profile.Default(nil), opts.timeout) reported := profileAsReported(detected) - // Through the seat mesh-enrolment (novox/hq ADR 0587 §6), the one way in: the controller's enrolment subject ended + // Through the seat mesh-node-enroller (novox/hq ADR 0587 §6), the one way in: the controller's enrolment subject ended // with the controller (issue 1126). return enrolThroughSeat(ctx, opts, token, *name, mine, sealing, serving, reported, found, identityPath) } diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 3d3b963..11e0ad3 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -173,7 +173,7 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s } } -// placedAllHave says whether every module the seat mesh-placement places on node has a have of its own, read from the +// placedAllHave says whether every module the seat mesh-module-assigner places on node has a have of its own, read from the // bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose placement or haves // cannot be read, keeps reading it. func placedAllHave(ctx context.Context, session *link.Session, node string) (bool, string) { @@ -228,7 +228,7 @@ func placedModules(node string, values map[string][]byte) (delivery.Placement, e // apply refused from then on). func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool, error)) (bool, string) { if len(p.Modules) == 0 { - return false, "the seat mesh-placement places no module here" + return false, "the seat mesh-module-assigner places no module here" } for _, m := range p.Modules { h, ok, err := has(m.Module) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index a7e887c..9d5c171 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -2,13 +2,13 @@ // and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126). // // **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its -// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a +// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-module-assigner's holder judging a // proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential // through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads. // // **What it reads, and from whom.** // - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the -// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1); +// seat mesh-module-assigner's state `placement`, one key per node (ADR 0567 §1); // - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and // accounts): the same placement key, Placement.Decided; // - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the diff --git a/internal/compose/resolution/placement_seat.go b/internal/compose/resolution/placement_seat.go index 1861c91..e2915ec 100644 --- a/internal/compose/resolution/placement_seat.go +++ b/internal/compose/resolution/placement_seat.go @@ -12,9 +12,9 @@ func placementVerbs() []mcat.Verb { "place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " + "unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " + "(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " + - "layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " + + "layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-node-enroller's alone, the " + "proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " + - "remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " + + "remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-node-enroller's alone, " + "on the same warrant)", "items": map[string]any{"type": "object", "required": []string{"op", "node"}}} return []mcat.Verb{ @@ -36,7 +36,7 @@ func placementVerbs() []mcat.Verb { "on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " + "hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " + "it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " + - "caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " + + "caller, as the runtime proves it. An add-node from mesh-node-enroller, whose warrant names its node and address, " + "is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"}, "properties": map[string]any{ diff --git a/internal/link/enrol.go b/internal/link/enrol.go index da23fe2..30a88d4 100644 --- a/internal/link/enrol.go +++ b/internal/link/enrol.go @@ -6,7 +6,7 @@ import ( ) // What a joining machine keeps of the enrolment's words: the tunnel it found, as it travels, and how long it asks -// while the seat mesh-enrolment says to ask again (Redeem). The enrolment through the controller's subject on the +// while the seat mesh-node-enroller says to ask again (Redeem). The enrolment through the controller's subject on the // CONTROL stream ended with the controller (issue 1126). // Approach is how a node reaches a mesh it does not yet belong to: the address, the certificate that address must diff --git a/internal/link/enrol_seat.go b/internal/link/enrol_seat.go index d6eb34f..0400a1f 100644 --- a/internal/link/enrol_seat.go +++ b/internal/link/enrol_seat.go @@ -15,7 +15,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" ) -// Joining a mesh through the seat mesh-enrolment (novox/hq ADR 0587 §6, issue 774): the node-engine dials the bus as enrol. +// Joining a mesh through the seat mesh-node-enroller (novox/hq ADR 0587 §6, issue 774): the node-engine dials the bus as enrol. // with the certificate's fingerprint pinned, calls `redeem` with the public halves of the keys it made where it runs and a // proof signed with its identity key, refuses an answer not signed by the key its token carries, keeps its membership, dials // again as the login node-logins names for it, and calls `confirm`. It is the one way in: the controller's enrolment subject @@ -23,7 +23,7 @@ import ( // EnrolmentSeat is the seat that adopts a new machine, and its two verbs a node-engine calls. const ( - EnrolmentSeat = "mesh-enrolment" + EnrolmentSeat = "mesh-node-enroller" EnrolmentRedeem = "redeem" EnrolmentConfirm = "confirm" ) @@ -35,7 +35,7 @@ func CalledSeatSubject(seat, verb, user string) string { // ErrNoSeat is the bus saying nothing serves the seat for this caller: the enrolment user may not publish the seat's call, or // no holder answers it. -var ErrNoSeat = errors.New("the seat mesh-enrolment is not served to this machine's token") +var ErrNoSeat = errors.New("the seat mesh-node-enroller is not served to this machine's token") // VerifyRedeemAnswer holds an answer to what it must be before anything is kept (ADR 0587 §6): it names the node asked for, // carries the signing key the token carries, and is signed by that key under the context of an enrolment's answer. An answer @@ -187,7 +187,7 @@ func (j Joining) Run(ctx context.Context) (delivery.RedeemAnswer, error) { return answer, fmt.Errorf("node-logins names %s for this node and the answer gave it %s: it is not confirmed", nl.Login, answer.Login) } if err := c.Confirm(ctx); err != nil { - return answer, fmt.Errorf("the enrolment is redeemed and not confirmed (a person ends it with mesh-enrolment.revoke): %w", err) + return answer, fmt.Errorf("the enrolment is redeemed and not confirmed (a person ends it with mesh-node-enroller.revoke): %w", err) } return answer, nil } diff --git a/internal/link/enrol_seat_test.go b/internal/link/enrol_seat_test.go index b1393a9..4217e44 100644 --- a/internal/link/enrol_seat_test.go +++ b/internal/link/enrol_seat_test.go @@ -98,7 +98,7 @@ func TestTheEngineKeepsOnlyAnAnswerSignedByTheKeyItsTokenCarries(t *testing.T) { if _, err := j.Run(ctx); err == nil || slices.Contains(steps, "confirm") { t.Errorf("a key naming another login was confirmed: %v, %v", err, steps) } - if CalledSeatSubject("mesh-enrolment", "redeem", "enrol.ace") != "mesh.seat.mesh-enrolment.call.redeem.enrol~ace" { + if CalledSeatSubject("mesh-node-enroller", "redeem", "enrol.ace") != "mesh.seat.mesh-node-enroller.call.redeem.enrol~ace" { t.Error("the call's subject does not name its caller as the composer grants it") } } -- 2.54.0 From 855f4471e276a7ee3c2eeb6879cd53d798c32a52 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:26:15 +0100 Subject: [PATCH 29/73] =?UTF-8?q?Node-engine:=20reads=20the=20seat=20list?= =?UTF-8?q?=20through=20the=20seat=20mesh-catalog,=20holds=20what=20is=20n?= =?UTF-8?q?ew=20until=20it=20was=20read,=20writes=20its=20finding=20after?= =?UTF-8?q?=20five=20minutes;=20verbs=20seat-list=20and=20seat-list-again;?= =?UTF-8?q?=20whole-module=20case=20against=20a=20stand-in=20of=20the=20se?= =?UTF-8?q?at=20(H-7,=20ADR=200636=20=C2=A71,=20=C2=A77,=20=C2=A78;=20issu?= =?UTF-8?q?e=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/main.go | 1 + cmd/mesh-host/nodeagent.go | 17 +- cmd/mesh-host/nodeagent_integration_test.go | 144 +++++++++++++++++ cmd/mesh-host/seatlist.go | 166 ++++++++++++++++++++ go.mod | 2 + internal/link/findings.go | 63 ++++++++ internal/link/handacts.go | 18 +++ internal/link/movesecret.go | 26 ++- internal/nodegrant/testdata/node.json | 7 +- 9 files changed, 436 insertions(+), 8 deletions(-) create mode 100644 cmd/mesh-host/seatlist.go create mode 100644 internal/link/findings.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 0d03f6b..a6d13b3 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1017,6 +1017,7 @@ func runLink(ctx context.Context, opts options) error { queue.Acts.Composition = compositionFrom(current) queue.Acts.Compose = composeFrom(current, &composerNow) queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow) + queue.Acts.SeatList, queue.Acts.SeatListAgain = seatListRead, seatListAgain queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow) setRunsHere(runsFromAgent(current)) factsOfNow := func(ctx context.Context, ag *agent.Agent) { diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 57dfbde..66af382 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -586,7 +586,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // The agent of this run alone: its composer tells it, never the agent of a run begun since (issue 1126: a pass in // flight at the switch marked the next agent's modules failed). own := &agentNow{} + // And the seat list it reads through the seat mesh-catalog, from its bootstrapped definitions until it has: the + // composer hands nothing new meanwhile, and the node says it has waited five minutes (novox/hq ADR 0636 §1). + seats, err := startSeatList(run, session, node, say) + if err != nil { + say(err.Error()) + } hooks := composerHooks{ + seatList: seats, running: func() []delivery.Composed { if now := own.get(); now != nil { return runsAsWritten(now.Everything(), opts.state) @@ -628,7 +635,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into // its own key, `.node`, under candidates. shadowHooks := hooks - shadowHooks.refused, shadowHooks.kept, shadowHooks.certify = nil, nil, nil + shadowHooks.refused, shadowHooks.kept, shadowHooks.certify, shadowHooks.seatList = nil, nil, nil, nil shadowEnv := composerEnv(session, node, say, shadowHooks) shadow := selfcompose.NewShadow(shadowEnv, func(ctx context.Context, answers map[string]delivery.CandidateAnswer) { @@ -982,6 +989,8 @@ type composerHooks struct { // vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify. vaultAuthority func() string certify func(ctx context.Context) error + // seatList is this run's reader of the seat list: the composer hands nothing new until it was read (ADR 0636 §1). + seatList *seatListState } // selfComposer is the node's own composer: it composes what the seat's placement places on the node, and nothing while @@ -1003,7 +1012,7 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f // composerEnv is what the node's composers read of the bus, the vault and the agent: its own composer and its answer // to placement candidates (selfcompose.Shadow) alike. func composerEnv(session *link.Session, node string, say func(string), hooks composerHooks) selfcompose.Env { - return selfcompose.Env{Node: node, + env := selfcompose.Env{Node: node, Watch: func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) { if bucket == delivery.PlacementBucket || bucket == delivery.PlacementCandidatesBucket { // The seat's states are made when their holder starts: a node-engine that starts first waits for the @@ -1033,4 +1042,8 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com Fetch: apply.Fetch, Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept, VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify} + if hooks.seatList != nil { + env.SeatList, env.SeatListChanged = hooks.seatList.reader, hooks.seatList.changed + } + return env } diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 4ae58f7..7eb33ea 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -18,12 +19,14 @@ import ( "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/nodegrant" + "github.com/novox/mesh-host/internal/seatwait" "github.com/novox/mesh-host/internal/selfcompose" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" @@ -55,6 +58,9 @@ type itBed struct { // refusals are the bus's asynchronous errors on the node's connection: each permissions violation it met. refusals []string mu sync.Mutex + // seatSilent makes the stand-in of the seat mesh-catalog refuse the seat list (it is served from the start otherwise). + seatSilent atomic.Bool + findings nats.KeyValue } // aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what @@ -133,6 +139,10 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed { } *c.into = kv } + if b.findings, err = b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: link.FindingsBucket, History: 1}); err != nil { + t.Fatal(err) + } + b.standInSeat() b.opts = options{state: filepath.Join(b.dir, "state", "state.json"), timeout: 2 * time.Second} if err := os.MkdirAll(filepath.Dir(b.opts.state), 0o755); err != nil { t.Fatal(err) @@ -875,3 +885,137 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test runtime.containers[server] == made, runtime.moved(server)) } } + +// standInSeat is the seat mesh-catalog as the node reads the seat list from it (novox/hq ADR 0636 §4): with no seat, every +// bootstrapped seat with a digest; with one, its definition. While seatSilent it refuses. +func (b *itBed) standInSeat() { + defs := map[string]json.RawMessage{} + var entries []seatlist.Entry + for _, d := range seatlist.Bootstrapped() { + raw, err := json.Marshal(d) + if err != nil { + b.t.Fatal(err) + } + defs[d.Seat] = raw + entries = append(entries, seatlist.Entry{Seat: d.Seat, Source: seatlist.SourceTrunk, Digest: "d-" + d.Seat}) + } + sub, err := b.conn.Subscribe("mesh.seat."+seatlist.Seat+".call."+seatlist.Verb+".*", func(m *nats.Msg) { + var q struct { + Seat string `json:"seat"` + } + _ = json.Unmarshal(m.Data, &q) + var result any + switch { + case b.seatSilent.Load(): + _ = m.Respond([]byte(`{"error":"the indexer has not started"}`)) + return + case q.Seat == "": + result = seatlist.Listing{Revision: 1, Seats: entries} + default: + result = seatlist.Answer{Seat: q.Seat, Found: true, Revision: 1, + Entry: &seatlist.Entry{Seat: q.Seat, Source: seatlist.SourceTrunk, Digest: "d-" + q.Seat, Definition: defs[q.Seat]}} + } + out, _ := json.Marshal(map[string]any{"result": result}) + _ = m.Respond(out) + }) + if err != nil { + b.t.Fatal(err) + } + b.t.Cleanup(func() { _ = sub.Unsubscribe() }) +} + +// fiveMinutes is a clock of the test: the bound of the node's wait fires when the test says so, nothing sleeps. +type fiveMinutes struct { + mu sync.Mutex + timers []*time.Timer + fire []func() +} + +func (c *fiveMinutes) Now() time.Time { return time.Now() } +func (c *fiveMinutes) AfterFunc(_ time.Duration, f func()) seatwait.Timer { + c.mu.Lock() + defer c.mu.Unlock() + t := &stoppable{} + c.fire = append(c.fire, func() { + if !t.stopped.Load() { + f() + } + }) + return t +} + +type stoppable struct{ stopped atomic.Bool } + +func (s *stoppable) Stop() bool { return !s.stopped.Swap(true) } + +func (c *fiveMinutes) pass() { + c.mu.Lock() + fire := c.fire + c.fire = nil + c.mu.Unlock() + for _, f := range fire { + f() + } +} + +// ADR 0636 §1, §8, whole against a real bus and the stand-in of the seat: while the seat refuses, the node composes and +// applies nothing new, and keeps running what it ran; after the bound it writes one finding in its state findings; when +// the seat names its list the module is applied and the finding is cleared; a node started again with the seat gone +// applies the composition it applied last. +func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { + clock := &fiveMinutes{} + was := seatWaitClock + seatWaitClock = clock + t.Cleanup(func() { seatWaitClock = was }) + + b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) + b.seatSilent.Store(true) + stop, st := h8(t, b, false) + defer stop() + _ = st + finding := "machine." + itNode + ".seat-list-unread" + within(t, 5*time.Second, "the node did not start its bound while the seat refused", func() bool { + clock.mu.Lock() + defer clock.mu.Unlock() + return len(clock.fire) > 0 + }) + if b.file("web") != "" { + t.Fatalf("the node applied web before it read the seat list: %q", b.file("web")) + } + if _, err := b.findings.Get(finding); err == nil { + t.Fatal("the node wrote its finding before the bound") + } + clock.pass() + within(t, 5*time.Second, "the node did not write its finding after the bound", func() bool { + _, err := b.findings.Get(finding) + return err == nil + }) + if b.file("web") != "" { + t.Fatalf("the node applied web with its finding written: %q", b.file("web")) + } + + b.seatSilent.Store(false) + // The hand act of the operator and the healers (the verb seat-list-again): a read now, as the seat's event would make. + if out, err := seatListAgain(); err != nil || out.(map[string]any)["error"] != nil { + t.Fatalf("seat-list-again: %v, %v", out, err) + } + within(t, 50*time.Second, "the node did not apply web once the seat named its list", func() bool { return b.file("web") == "greeting=bonjour\n" }) + within(t, 5*time.Second, "the node did not clear its finding at the first full read", func() bool { + _, err := b.findings.Get(finding) + return errors.Is(err, nats.ErrKeyNotFound) + }) + + // Started again with the seat refusing: nothing new is composed, and what ran is neither removed nor changed. + stop() + b.seatSilent.Store(true) + stopAgain := b.start() + defer stopAgain() + within(t, 30*time.Second, "the node did not hold web as it was after a start with the seat gone", func() bool { + b.mu.Lock() + defer b.mu.Unlock() + return strings.Contains(strings.Join(b.said, "\n"), "it1 has read no complete seat list") || b.file("web") != "" + }) + if b.file("web") != "greeting=bonjour\n" { + t.Errorf("web does not stay as it ran after a start with the seat gone: %q", b.file("web")) + } +} diff --git a/cmd/mesh-host/seatlist.go b/cmd/mesh-host/seatlist.go new file mode 100644 index 0000000..ca6daec --- /dev/null +++ b/cmd/mesh-host/seatlist.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync/atomic" + "time" + + conditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/views" + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/seatwait" +) + +// The node-engine reads the seat list as every part does (novox/hq ADR 0635 §1, ADR 0636): through the seat mesh-catalog, +// from the bootstrapped definitions until it has been read, acting on nothing new meanwhile (the composer's gate), and +// following the seat's events. What it holds is given to catalogue at every change, which compiles no seat list. + +// seatListNow is this run's reader, what the node-engine's verbs seat-list and seat-list-again answer from. +var seatListNow atomic.Pointer[seatListState] + +// seatListRead answers the verb seat-list: what the engine holds of the list and what it waits for (ADR 0636 §7). +func seatListRead() (any, error) { + st := seatListNow.Load() + if st == nil { + return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet") + } + status := st.reader.Status() + return map[string]any{"status": status, "health": status.Health()}, nil +} + +// seatListAgain answers the verb seat-list-again: the list read now from the seat, and what is held afterwards. +func seatListAgain() (any, error) { + st := seatListNow.Load() + if st == nil { + return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet") + } + err := st.reader.Reload() + st.waiter.Changed() // a read that changed nothing held still ends the wait + out, _ := seatListRead() + if err != nil { + out.(map[string]any)["error"] = err.Error() + } + return out, nil +} + +// seatListState is one run's reader, the finding that it waits, and the channel on which the composer hears a change. +type seatListState struct { + reader *seatlist.Reader + waiter *seatwait.Waiter + changed chan struct{} +} + +// sessionBus is the reader's bus over the link: a verb of the seat is asked in the engine's own name, an event heard on +// the seat's event subject for as long as ctx lives. +type sessionBus struct { + ctx context.Context + session *link.Session +} + +func (b sessionBus) Ask(key string, body any) (json.RawMessage, error) { + seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + return b.session.AskMeshSeat(b.ctx, seat, verb, body) +} + +func (b sessionBus) Subscribe(pattern string, handle func(string, json.RawMessage) error) error { + seat, event, _ := strings.Cut(pattern, ".") + heard, stop, err := b.session.Hear(seatdef.EventSubject(seat, event)) + if err != nil { + return err + } + go func() { + defer stop() + for { + select { + case <-b.ctx.Done(): + return + case body, ok := <-heard: + if !ok { + return + } + _ = handle(pattern, body) + } + } + }() + return nil +} + +// seatWaitClock is the clock of the node's bound; the tests give their own. +var seatWaitClock seatwait.Clock = realClock{} + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } +func (realClock) AfterFunc(d time.Duration, f func()) seatwait.Timer { + return time.AfterFunc(d, f) +} + +// findingsOfTheNode writes the node's findings in its state `findings`, in the layout of the seat mesh-conditions. +type findingsOfTheNode struct{ session *link.Session } + +func (w findingsOfTheNode) encode(f views.Finding) (string, []byte, error) { + key := conditions.Key(conditions.ScopeMachine, f.ID, f.Kind) + v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning, + Subject: condseat.Subject{Scope: conditions.ScopeMachine, ID: f.ID, Machine: f.ID}, Summary: f.Summary, + Headline: f.Headline, Explanation: f.Explanation, Source: "seat-list-unread", At: f.At.UTC().Format(time.RFC3339)} + if err := conditions.CheckFinding(v); err != nil { + return "", nil, err + } + raw, err := json.Marshal(v) + return key, raw, err +} + +func (w findingsOfTheNode) Put(f views.Finding) error { + key, raw, err := w.encode(f) + if err != nil { + return err + } + return w.session.PutFinding(key, raw) +} + +func (w findingsOfTheNode) Delete(f views.Finding) error { + key, _, err := w.encode(f) + if err != nil { + return err + } + return w.session.DeleteFinding(key) +} + +// startSeatList starts this run's reader: its bootstrapped seats are given to catalogue at once, and every change of what +// it holds again, saying so to the composer and to the waiter that writes the five-minute finding. +func startSeatList(ctx context.Context, session *link.Session, node string, say func(string)) (*seatListState, error) { + st := &seatListState{changed: make(chan struct{}, 1)} + var announce func() + reader, err := seatlist.Start(seatlist.Config{Bus: sessionBus{ctx, session}, Bootstrap: seatlist.Bootstrapped(), + OnChange: func(seats []catalogue.Seat) { + catalogue.UseSeats(seats) + if announce != nil { // the first read happens inside Start, before the waiter exists + announce() + } + }}) + if err != nil { + return nil, fmt.Errorf("the node-engine cannot follow the seat list: %w", err) + } + st.reader = reader + st.waiter = &seatwait.Waiter{Node: node, Observer: "node-engine", Reader: reader, Findings: findingsOfTheNode{session}, + Clock: seatWaitClock, Say: say} + announce = func() { + st.waiter.Changed() + select { + case st.changed <- struct{}{}: + default: + } + } + catalogue.UseSeats(reader.Seats()) + announce() + seatListNow.Store(st) + return st, nil +} diff --git a/go.mod b/go.mod index 1203a31..17392cf 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639 diff --git a/internal/link/findings.go b/internal/link/findings.go new file mode 100644 index 0000000..0b0ba3f --- /dev/null +++ b/internal/link/findings.go @@ -0,0 +1,63 @@ +package link + +import ( + "errors" + "fmt" + + "github.com/nats-io/nats.go" +) + +// FindingsBucket is the node-engine's state `findings`: the findings this engine writes in its own bucket, one key each, +// which the condition keeper raises as conditions and clears when the key goes (novox/hq ADR 0564 §2, ADR 0636 §1). +const FindingsBucket = "mesh-host_findings" + +func (l *natsLink) findingsKV() (nats.KeyValue, error) { + kv, err := l.js.KeyValue(FindingsBucket) + if err != nil { + return nil, fmt.Errorf("the node-engine's state %s cannot be bound (it is created from the node-engine's manifest; "+ + "written again once it is): %w", FindingsBucket, err) + } + return kv, nil +} + +func (q *Session) findings() (nats.KeyValue, error) { + q.init() + q.mu.Lock() + bus := q.bus + q.mu.Unlock() + l, ok := bus.(interface{ findingsKV() (nats.KeyValue, error) }) + if bus == nil || !ok { + return nil, ErrNoLink + } + return l.findingsKV() +} + +// PutFinding writes one finding of this engine over the link open now. +func (q *Session) PutFinding(key string, value []byte) error { + kv, err := q.findings() + if err != nil { + return err + } + _, err = kv.Put(key, value) + return err +} + +// DeleteFinding clears one finding of this engine over the link open now; a finding already gone is cleared. +func (q *Session) DeleteFinding(key string) error { + kv, err := q.findings() + if err != nil { + return err + } + if err = kv.Delete(key); err != nil && !errors.Is(err, nats.ErrKeyNotFound) { + return err + } + return nil +} + +func (o OverNATS) findingsKV() (nats.KeyValue, error) { + kv, err := o.JS.KeyValue(FindingsBucket) + if err != nil { + return nil, fmt.Errorf("the node-engine's state %s cannot be bound: %w", FindingsBucket, err) + } + return kv, nil +} diff --git a/internal/link/handacts.go b/internal/link/handacts.go index a192515..bb0cf84 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -51,6 +51,11 @@ const ( ActComposeCandidate = nodeengine.VerbComposeCandidate // ActManifestReads reads the reader capabilities this engine states (novox/hq ADR 0618 §3). ActManifestReads = nodeengine.VerbManifestReads + // ActSeatList reads the seat list this engine holds and what it waits for; ActSeatListAgain makes it read the list now + // (novox/hq ADR 0636 §7). The node-engine seat's definition carries both verbs, which answer only the operator and the + // healers. + ActSeatList = "seat-list" + ActSeatListAgain = "seat-list-again" ) // The states of an act: asked, done and refused are also the seat's events. @@ -149,6 +154,9 @@ type HandActs struct { // Packages answers the node's key of the engine's state packages beside the controller's mirror's key and their // difference, and with write writes the key again from what the engine composed (the verb packages, ADR 0565 §6, §15). Packages func(write bool) (any, error) + // SeatList answers the seat list the engine holds; SeatListAgain makes it read the list now and answers what it holds. + SeatList func() (any, error) + SeatListAgain func() (any, error) // ComposeCandidate composes a check's candidate (the indexer's candidate key) on this node in shadow and answers per // module, applying and writing nothing; with input, also the composition input it composed from (the verb // compose-candidate, ADR 0593 §7). @@ -219,6 +227,16 @@ func (h *HandActs) Call(c EngineCall) (any, error) { return nil, errors.New("this engine composes nothing on a call yet: its node agent has not started") } return h.Compose(arg("module")) + case ActSeatList: + if h.SeatList == nil { + return nil, errors.New("this engine reads no seat list yet: its node agent has not started") + } + return h.SeatList() + case ActSeatListAgain: + if h.SeatListAgain == nil { + return nil, errors.New("this engine reads no seat list yet: its node agent has not started") + } + return h.SeatListAgain() case ActPackages: if h.Packages == nil { return nil, errors.New("this engine keeps no packages yet: its node agent has not started") diff --git a/internal/link/movesecret.go b/internal/link/movesecret.go index cb17df0..8819d8a 100644 --- a/internal/link/movesecret.go +++ b/internal/link/movesecret.go @@ -181,7 +181,12 @@ func CalledVaultSubject(verb, node string) string { // CalledVaultSubjectAs is CalledVaultSubject for the login the engine is dialled as (node. or node.--b, ADR 0583 §1): // the bus lets only that user publish a subject naming it. func CalledVaultSubjectAs(verb, user string) string { - return "mesh.seat." + VaultSeat + ".call." + verb + "." + strings.ReplaceAll(user, ".", "~") + return CalledSeatSubjectAs(VaultSeat, verb, user) +} + +// CalledSeatSubjectAs is where a verb of a seat held once for the mesh is asked naming the login the engine is dialled as. +func CalledSeatSubjectAs(seat, verb, user string) string { + return "mesh.seat." + seat + ".call." + verb + "." + strings.ReplaceAll(user, ".", "~") } // VaultWithin is how long the engine gives the vault to answer one step. @@ -190,6 +195,17 @@ const VaultWithin = 15 * time.Second // AskVault asks one of the vault's verbs in this node's engine's own name over the link open now, and answers its // result, or its refusal as an error. func (q *Session) AskVault(ctx context.Context, verb string, args any) (json.RawMessage, error) { + return q.askCalled(ctx, VaultSeat, verb, args, VaultWithin, "the vault's "+verb) +} + +// AskMeshSeat asks a verb of a seat held once for the mesh in this node's engine's own name over the link open now, and +// answers its result, or its refusal as an error: the way the engine reads the seat list from the seat mesh-catalog +// (novox/hq ADR 0636 §4). +func (q *Session) AskMeshSeat(ctx context.Context, seat, verb string, args any) (json.RawMessage, error) { + return q.askCalled(ctx, seat, verb, args, VaultWithin, seat+"."+verb) +} + +func (q *Session) askCalled(ctx context.Context, seat, verb string, args any, within time.Duration, what string) (json.RawMessage, error) { q.init() q.mu.Lock() bus := q.bus @@ -205,18 +221,18 @@ func (q *Session) AskVault(ctx context.Context, verb string, args any) (json.Raw if err != nil { return nil, err } - asking, cancel := context.WithTimeout(ctx, VaultWithin) + asking, cancel := context.WithTimeout(ctx, within) defer cancel() - reply, err := asker.Ask(asking, CalledVaultSubjectAs(verb, q.Membership.User()), body) + reply, err := asker.Ask(asking, CalledSeatSubjectAs(seat, verb, q.Membership.User()), body) if err != nil { - return nil, fmt.Errorf("the vault's %s could not be asked: %w", verb, err) + return nil, fmt.Errorf("%s could not be asked: %w", what, err) } var env struct { Result json.RawMessage `json:"result"` Error string `json:"error"` } if err := json.Unmarshal(reply, &env); err != nil { - return nil, fmt.Errorf("the vault answered %s with something that is not a tool's answer", verb) + return nil, fmt.Errorf("%s answered with something that is not a tool's answer", what) } if env.Error != "" { return nil, errors.New(env.Error) diff --git a/internal/nodegrant/testdata/node.json b/internal/nodegrant/testdata/node.json index 16ce048..bfb8c60 100644 --- a/internal/nodegrant/testdata/node.json +++ b/internal/nodegrant/testdata/node.json @@ -67,6 +67,7 @@ "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts", "$JS.API.STREAM.INFO.KV_mesh-host_acts", "$JS.API.STREAM.INFO.KV_mesh-host_data", + "$JS.API.STREAM.INFO.KV_mesh-host_findings", "$JS.API.STREAM.INFO.KV_mesh-host_health", "$JS.API.STREAM.INFO.KV_mesh-host_packages", "$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions", @@ -91,6 +92,7 @@ "$KV.composed-NODE.>", "$KV.mesh-host_acts.NODE.>", "$KV.mesh-host_data.NODE.>", + "$KV.mesh-host_findings.>", "$KV.mesh-host_health.NODE", "$KV.mesh-host_packages.NODE", "$KV.node-engine_have.NODE.>", @@ -100,6 +102,7 @@ "$SRV.PING.>", "$SRV.PING.node-tools.NODE", "mesh.control.NODE.>", + "mesh.seat.mesh-catalog.call.seat-definitions.node~NODE", "mesh.seat.node-backup.call.backed-up.NODE.node~NODE", "mesh.seat.node-backup.call.now.NODE.node~NODE", "mesh.seat.node-backup.tool.backed-up.NODE", @@ -130,8 +133,10 @@ "mesh.node.NODE.ask.report", "mesh.node.NODE.ask.setuid-search", "mesh.node.NODE.declare", + "mesh.seat.mesh-catalog.event.seat-definition-changed", + "mesh.seat.mesh-catalog.event.seat-definition-removed", "mesh.seat.node-engine.call.*.NODE.*", "mesh.seat.node-engine.tool.*.NODE" ], "AllowResponses": true -} \ No newline at end of file +} -- 2.54.0 From f29b19aff229fc1c5545c95da7d745d22e445719 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:30:08 +0100 Subject: [PATCH 30/73] Node-engine manifest: invokes the seat mesh-catalog's seat-definitions, consumes its two events, keeps the state findings (H-7, ADR 0636; issue 1966) --- module.json | 8 ++++++++ packaging/operator_keys_test.go | 15 ++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/module.json b/module.json index b71573b..2f3da06 100644 --- a/module.json +++ b/module.json @@ -14,13 +14,21 @@ } ] }, + "invokes": [ + "seat:mesh-catalog.seat-definitions" + ], "consumes": [ + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed", "restic.backed-up" ], "state": [ { "name": "health" }, + { + "name": "findings" + }, { "name": "data" }, diff --git a/packaging/operator_keys_test.go b/packaging/operator_keys_test.go index 5c77c08..487579a 100644 --- a/packaging/operator_keys_test.go +++ b/packaging/operator_keys_test.go @@ -3,6 +3,7 @@ package packaging_test import ( "encoding/json" "os" + "slices" "strings" "testing" @@ -65,8 +66,9 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - if len(m.Consumes) != 1 || m.Consumes[0] != "restic.backed-up" { - t.Fatalf("consumes %v", m.Consumes) + // The backup event, and the two events of the seat that serves the seat list (ADR 0636 §4). + if want := []string{"mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "restic.backed-up"}; !slices.Equal(m.Consumes, want) { + t.Fatalf("consumes %v, want %v", m.Consumes, want) } // 774: the acts bucket mesh-host_acts is not declared as state for now: a declared state of a module with no account // of its own is carried by every node's tool runner, which would be a second writer of the engine's own keys and @@ -87,9 +89,12 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) { // ADR 0577 clause 1: and the state data, one key per module placed on the node that declares data items, kept until // the module leaves the node; created by the grants part from this manifest as health is. // ADR 0565 clause 6: and the state packages, one key per node, its compositions' declared packages, read by pacman. - if len(parsed.State) != 3 || parsed.State[0].Name != "health" || parsed.State[0].TTLSeconds != 0 || - parsed.State[1].Name != "data" || parsed.State[1].TTLSeconds != 0 || parsed.State[2].Name != "packages" || - parsed.State[2].TTLSeconds != 0 { + // ADR 0564 §2, ADR 0636 §1: and the state findings, the node's finding that it waits for the seat list (no TTL: the + // finding stays until the first full read deletes its key). + if len(parsed.State) != 4 || parsed.State[0].Name != "health" || parsed.State[0].TTLSeconds != 0 || + parsed.State[1].Name != "findings" || parsed.State[1].TTLSeconds != 0 || + parsed.State[2].Name != "data" || parsed.State[2].TTLSeconds != 0 || parsed.State[3].Name != "packages" || + parsed.State[3].TTLSeconds != 0 { t.Fatalf("the manifest declares the state %+v", parsed.State) } } -- 2.54.0 From c642471c166c4fc80f26ce132d95df81e323a8cb Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:45:43 +0100 Subject: [PATCH 31/73] =?UTF-8?q?Node-engine:=20the=20first=20read=20of=20?= =?UTF-8?q?the=20seat=20list=20is=20awaited=20on=20the=20reader's=20Ready,?= =?UTF-8?q?=20not=20on=20Start's=20return,=20and=20its=20end=20is=20told?= =?UTF-8?q?=20to=20the=20waiter=20and=20the=20composer=20(B9c,=20ADR=20063?= =?UTF-8?q?6=20=C2=A71;=20issue=201966)=20Claude-Session:=20https://claude?= =?UTF-8?q?.ai/code/session=5F01NcbgfFSnn24M3GcqttoU7i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/nodeagent_integration_test.go | 22 ++ cmd/mesh-host/seatlist.go | 23 +- internal/overlay/declaration.go | 187 ----------- internal/overlay/declaration_test.go | 305 ------------------ internal/overlay/generator.go | 151 --------- internal/overlay/generator_test.go | 30 -- internal/overlay/graph.go | 231 -------------- internal/overlay/graph_test.go | 333 -------------------- internal/overlay/names.go | 30 -- internal/overlay/opens_test.go | 113 ------- 10 files changed, 40 insertions(+), 1385 deletions(-) delete mode 100644 internal/overlay/declaration.go delete mode 100644 internal/overlay/declaration_test.go delete mode 100644 internal/overlay/generator.go delete mode 100644 internal/overlay/generator_test.go delete mode 100644 internal/overlay/graph.go delete mode 100644 internal/overlay/graph_test.go delete mode 100644 internal/overlay/names.go delete mode 100644 internal/overlay/opens_test.go diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 7eb33ea..ba08a53 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -1019,3 +1019,25 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { t.Errorf("web does not stay as it ran after a start with the seat gone: %q", b.file("web")) } } + +// The first read of the seat list ends the wait whether or not it changed what the reader held: Start returns at once, and +// the node waits on the reader's Ready before it says the read is over (ADR 0636 §1). +func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) { + clock := &fiveMinutes{} + was := seatWaitClock + seatWaitClock = clock + t.Cleanup(func() { seatWaitClock = was }) + + b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) + stop, _ := h8(t, b, false) + defer stop() + within(t, 30*time.Second, "the node did not end its wait at the first read of the seat list", func() bool { + st := seatListNow.Load() + if st == nil { + return false + } + <-st.reader.Ready() + waiting, _ := st.waiter.Waiting() + return st.reader.Status().Heard && !waiting + }) +} diff --git a/cmd/mesh-host/seatlist.go b/cmd/mesh-host/seatlist.go index ca6daec..6ad0ce2 100644 --- a/cmd/mesh-host/seatlist.go +++ b/cmd/mesh-host/seatlist.go @@ -138,12 +138,13 @@ func (w findingsOfTheNode) Delete(f views.Finding) error { // it holds again, saying so to the composer and to the waiter that writes the five-minute finding. func startSeatList(ctx context.Context, session *link.Session, node string, say func(string)) (*seatListState, error) { st := &seatListState{changed: make(chan struct{}, 1)} - var announce func() + // Start returns at once and reads the seat on a goroutine, which may announce a change before the waiter exists. + var announce atomic.Pointer[func()] reader, err := seatlist.Start(seatlist.Config{Bus: sessionBus{ctx, session}, Bootstrap: seatlist.Bootstrapped(), OnChange: func(seats []catalogue.Seat) { catalogue.UseSeats(seats) - if announce != nil { // the first read happens inside Start, before the waiter exists - announce() + if f := announce.Load(); f != nil { // the first read may end before the waiter exists + (*f)() } }}) if err != nil { @@ -152,15 +153,27 @@ func startSeatList(ctx context.Context, session *link.Session, node string, say st.reader = reader st.waiter = &seatwait.Waiter{Node: node, Observer: "node-engine", Reader: reader, Findings: findingsOfTheNode{session}, Clock: seatWaitClock, Say: say} - announce = func() { + told := func() { st.waiter.Changed() select { case st.changed <- struct{}{}: default: } } + announce.Store(&told) catalogue.UseSeats(reader.Seats()) - announce() + told() + // The first read ends whether the seat answered or not, and a read that held what the reader held already says no + // change: its end is told to the waiter and the composer, so a status that moved from reading to heard starts or ends + // the bound (ADR 0636 §1). Start's return held nobody's start. + go func() { + select { + case <-reader.Ready(): + catalogue.UseSeats(reader.Seats()) + told() + case <-ctx.Done(): + } + }() seatListNow.Store(st) return st, nil } diff --git a/internal/overlay/declaration.go b/internal/overlay/declaration.go deleted file mode 100644 index b248bdd..0000000 --- a/internal/overlay/declaration.go +++ /dev/null @@ -1,187 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" -) - -// The overlay is delivered as an ordinary declaration. -// -// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list, -// as files. It does not compute them and it does not know what a private network is — the shapes -// it already has are enough, which is why connectivity needs nothing new from tier 0. -// -// **No private key travels.** The configuration points at a file the node wrote from a key the -// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the -// control plane composes a complete configuration for a node it cannot impersonate. - -// Resource is one entry in a declaration, built here and read by the host. -type Resource map[string]any - -// Declaration is what the mesh sends a node to put it on the network. -// -// Three resources and nothing clever: the tools, the configuration, and the interface running. A -// person can read it, which is the point — this is the first thing a node is ever told, and if it -// is wrong the node is unreachable and the mistake has to be findable by eye. -func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { - if node.Address == "" { - return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure", - node.Name) - } - if keyPath == "" { - keyPath = DefaultKeyPath - } - - up := Resource{ - "id": "overlay-up", "type": "service", "unit": Unit, - "state": "running", - // Enabled, so the node comes back onto the network after a reboot without waiting to - // be told again. A node whose overlay only exists while something is watching is not - // a node that survives being switched off and on. - "boot": "enabled", - // And restarted when the peer list changes, because a running interface does not - // re-read its configuration. - // - // This is the whole of it: a node joins, every existing node's peer list changes, - // each file is replaced — and without this the service is already running, nothing - // reloads it, and every node keeps a network that no longer matches the mesh. It - // reports complete success. The lab found it the moment a third node arrived. - // - // Declared state rather than a command. The service must reflect the file; the host - // works out that it does not. A command to restart would be an action, and the link - // may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly, - // which is how this shape was arrived at. - "restart-on": []string{"overlay-config"}, - } - if node.TakesOver != nil { - // The private network takes over the tunnel it found (novox/hq ADR 0105): before this - // unit starts, the host stops and disables the found one — never flushing it — and keeps - // its configuration like any held file. The key is already the found one: the node took - // it as its own overlay key when it enrolled, which is why the mesh's peer list for it - // carries the found peers under the key they know. - up["takes-over"] = map[string]any{ - "interface": node.TakesOver.Interface, - "unit": node.TakesOver.Unit, - "config": node.TakesOver.Config, - } - } - - resources := []Resource{ - { - "id": "overlay-tools", "type": "package", "package": "wireguard-tools", - }, - { - "id": "overlay-config", "type": "file", "path": ConfigPath, - // Readable only by root: it lists every peer's key and endpoint, which is a map of - // the mesh. Not secret in the way a private key is, and not something to leave - // world-readable on a machine somebody else also uses. - "mode": "0600", - "content": config(node, peers, keyPath), - }, - up, - } - - // The names used to be appended here, on the argument that a node with peers and no names is - // half on the network. True, and the wrong place to fix it: names would be identical over a - // different private network, so bundling them with WireGuard made one module out of two - // things. They are their own module now, requiring this one — which is what keeps them - // arriving together without pretending they are the same concern. - return json.Marshal(map[string]any{"declaration": 1, "resources": resources}) -} - -// config writes the interface file. -// -// Deliberately in the order a person would read it: who I am, then who I talk to, each with a -// line saying why it is there. A generated file that cannot be understood by the person it -// confuses is a generated file that gets edited by hand. -func config(node Node, peers []Peer, keyPath string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") - b.WriteString("# peer graph changes, and an edit would survive until the next change and\n") - b.WriteString("# then vanish, which is worse than not being applied at all.\n") - fmt.Fprintf(&b, "#\n# node %s", node.Name) - if node.Site != "" { - fmt.Fprintf(&b, ", at %s", node.Site) - } - if !node.Reachable() { - b.WriteString(", not dialable — it opens every path itself") - } - b.WriteString("\n\n[Interface]\n") - fmt.Fprintf(&b, "Address = %s/32\n", node.Address) - if node.Reachable() { - if port := portOf(node.Endpoint); port != "" { - fmt.Fprintf(&b, "ListenPort = %s\n", port) - } - } else if node.TakesOver != nil && node.TakesOver.Port != 0 { - // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over, - // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings - // its port). Without this the takeover guard refuses overlay-up, and re-placing the node - // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. - fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) - } - // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS - // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows - // it (novox/hq: a taken tunnel carries its MTU). - if node.TakesOver != nil && node.TakesOver.MTU != 0 { - fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU) - } - // The private key is set from a file the node wrote, so it never appears here and never - // travelled. Everything else in this file came from the mesh; this one line is the node's. - fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath) - - if node.Hub { - // A hub carries traffic *between* its spokes, and a Linux machine does not forward - // packets unless it is told to. Without this every spoke reaches the hub perfectly and - // no spoke reaches any other — which is exactly how it failed in the lab, and it looks - // like a peering problem rather than a kernel setting. - // - // Here rather than in a separate resource because it is part of what being a hub means, - // and because it should last exactly as long as the interface does: a machine that stops - // being the hub should stop forwarding, and PostDown below is how that happens. - b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n") - b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n") - - // And past the machine's own firewall, which on any node with a container runtime is - // closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation - // this mesh installs at tier 1 silently breaks the network it builds at tier 2: every - // spoke reaches the hub, no spoke reaches any other, and every part of it reports - // success. Found in the lab; nothing about it is visible from the mesh's own state. - // - // Inserted at the top so it precedes those chains, and removed on the way down so a - // machine that stops being the hub stops carrying other people's traffic. Guarded on - // iptables existing: a machine without it has no policy to get past. - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n", - direction) - } - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n", - direction) - } - } - - for _, p := range peers { - fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why) - fmt.Fprintf(&b, "PublicKey = %s\n", p.Key) - fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed) - if p.Endpoint != "" { - fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint) - } else { - b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n") - } - if p.Keepalive { - b.WriteString("PersistentKeepalive = 25\n") - } - } - return b.String() -} - -func portOf(endpoint string) string { - if i := strings.LastIndex(endpoint, ":"); i >= 0 { - return endpoint[i+1:] - } - return "" -} diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go deleted file mode 100644 index af54dcd..0000000 --- a/internal/overlay/declaration_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" - "testing" -) - -func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) { - t.Helper() - raw, err := Declaration(node, peers, "") - if err != nil { - t.Fatal(err) - } - var d struct { - Declaration int `json:"declaration"` - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &d); err != nil { - t.Fatal(err) - } - if d.Declaration != 1 { - t.Fatalf("declaration version %d", d.Declaration) - } - for _, r := range d.Resources { - if r["type"] == "file" { - return r["content"].(string), d.Resources - } - } - t.Fatal("the declaration has no configuration file in it") - return "", nil -} - -func TestNoPrivateKeyEverTravels(t *testing.T) { - // The property the whole design rests on: the node generated its keypair and kept the private - // half, so the mesh composes a configuration for a node it cannot impersonate. A private key - // appearing here would mean the control plane had one — and a copy of its database would then - // be every node's network identity. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}}) - - if strings.Contains(config, "PrivateKey") { - t.Error("the configuration carries a PrivateKey line; the mesh must never hold one") - } - if !strings.Contains(config, "private-key "+DefaultKeyPath) { - t.Error("the configuration does not point at the key file the node wrote, so the " + - "interface would come up with no key at all") - } -} - -func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) { - // Connectivity needs nothing new from tier 0, and that is worth holding: the host does not - // know what a private network is, and should not learn. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - - allowed := map[string]bool{"package": true, "file": true, "service": true, - "directory": true, "container": true, "action": true} - for _, r := range resources { - if !allowed[r["type"].(string)] { - t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"]) - } - } -} - -func TestTheInterfaceComesBackAfterAReboot(t *testing.T) { - // A node whose overlay only exists while something is watching is not a node that survives - // being switched off and on — and it would come back unreachable, which is the worst way to - // come back. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "service" { - if r["boot"] != "enabled" { - t.Error("the overlay interface is not enabled at boot") - } - if r["state"] != "running" { - t.Error("the overlay interface is not asked to be running") - } - return - } - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheConfigurationIsNotWorldReadable(t *testing.T) { - // It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a - // private key is, and not something to leave readable on a machine somebody else also uses. - // The peer list specifically, not every file. `/etc/hosts` is in here too and must be - // world-readable, or nothing on the machine resolves anything — a check that swept all files - // would force it to 0600 and break the machine to protect a file that is not secret. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["id"] == "overlay-config" && r["mode"] != "0600" { - t.Errorf("the peer list is mode %v", r["mode"]) - } - if r["id"] == "mesh-names" && r["mode"] != "0644" { - t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"]) - } - } -} - -func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) { - // A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody - // helpfully adding one that cannot work. - config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, - []Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}}) - - if strings.Contains(config, "Endpoint =") { - t.Error("an endpoint was written for a peer that has none") - } - if !strings.Contains(config, "cannot be dialled") { - t.Error("the file does not say why that peer has no endpoint") - } -} - -func TestTheFileSaysNotToEditIt(t *testing.T) { - // It is replaced whenever the graph changes. An edit survives until then and vanishes, which - // is worse than never being applied — the machine works, then stops, and nothing changed - // that anybody remembers. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if !strings.Contains(config, "Do not edit") { - t.Error("a generated file does not say it is generated") - } -} - -func TestANodeWithNoAddressIsRefused(t *testing.T) { - // Rather than a configuration with a blank address, which wg-quick would reject on the - // machine, at boot, where the failure is much harder to see. - if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil { - t.Fatal("a node with no overlay address was given a configuration") - } -} - -func TestOnlyAReachableNodeListens(t *testing.T) { - // A ListenPort on a node nothing can dial is a port open for no reason. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(config, "ListenPort") { - t.Error("a node that cannot be dialled was told to listen") - } - config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820"}, nil) - if !strings.Contains(config, "ListenPort = 51820") { - t.Error("a reachable node does not listen on the port its endpoint names") - } -} - -func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) { - // The fault this exists to catch, found in the lab the moment a third node arrived: a running - // WireGuard interface does not re-read its configuration. The file was replaced, the service - // was already running so nothing reloaded it, and every existing node kept a network that no - // longer matched the mesh — while looking entirely successful. - // - // So the declaration has to verify what the interface is *carrying*, not what the file says. - // - // And it must be declared state rather than a command: the host refuses an action arriving - // over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There - // is a test below that no action is ever in here. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}}) - - for _, r := range resources { - if r["type"] != "service" { - continue - } - reflects := fmt.Sprint(r["restart-on"]) - if !strings.Contains(reflects, "overlay-config") { - t.Errorf("the interface does not restart when its configuration changes: %v", reflects) - } - return - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) { - // The link may not carry an action, and the host refuses a declaration containing one — whole, - // not in part. An overlay declaration with an action in it does not half-apply: it leaves the - // node with no network at all. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "action" { - t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+ - "thing and the node will have no network", r["id"]) - } - } -} - -func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) { - // A hub carries traffic between its spokes, and Linux does not forward packets unless told - // to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which - // is how it failed in the lab, and it presents as a peering problem rather than a kernel - // setting, so it is worth being sure of. - hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, nil) - if !strings.Contains(hub, "ip_forward=1") { - t.Error("the hub does not enable forwarding, so its spokes cannot reach each other") - } - if !strings.Contains(hub, "ip_forward=0") { - t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " + - "passing traffic it is no longer part of") - } - - // And past the machine's own firewall. Any node with a container runtime has FORWARD set to - // DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it - // failed, with every spoke reaching the hub and no spoke reaching any other. - // Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone, - // which would leave a hub that tears down rules it never put up. - if !strings.Contains(hub, "PostUp = command -v iptables") { - t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " + - "silently eats everything it was supposed to carry") - } - if !strings.Contains(hub, "PostDown = command -v iptables") { - t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " + - "passing traffic it is no longer part of") - } - - spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(spoke, "FORWARD") { - t.Error("a spoke was given forwarding rules it has no use for") - } - if strings.Contains(spoke, "ip_forward") { - t.Error("a spoke was told to forward packets, which is not its job and widens what a " + - "compromised one could do") - } -} - -// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on -// the interface's service, and nothing else about the declaration changes — the key is already -// the found one, taken at enrolment. -func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) { - node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true, - Endpoint: "198.51.100.1:51900", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}} - config, resources := declarationFor(t, node, nil) - - var up map[string]any - for _, r := range resources { - if r["type"] == "service" { - up = r - } - } - takes, ok := up["takes-over"].(map[string]any) - if !ok { - t.Fatalf("the interface's service does not say what it takes over: %+v", up) - } - if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" { - t.Errorf("the takeover names the wrong tunnel: %+v", takes) - } - if !strings.Contains(config, "ListenPort = 51900") { - t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config) - } - if strings.Contains(config, "PrivateKey") { - t.Error("the found key travelled in the configuration; it is the node's own, set from its key file") - } - - _, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil) - for _, r := range plain { - if _, says := r["takes-over"]; says { - t.Error("a node taking over nothing was told to take something over") - } - } -} - -func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) { - // A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still - // listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel - // brings its port). Without this the takeover guard refuses overlay-up. - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820}, - }, nil) - - if !strings.Contains(config, "ListenPort = 51820") { - t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config) - } - if strings.Contains(config, "Endpoint =") { - t.Error("a node that only listens for LAN peers must not advertise an endpoint") - } -} - -func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { - // The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel - // writes no ListenPort — it purely dials out. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil) - if strings.Contains(config, "ListenPort") { - t.Fatalf("a dial-only node needs no ListenPort:\n%s", config) - } -} - -// A path tuned to a smaller MTU (1380 here) survives the takeover: the mesh interface comes up with the same MTU, or -// transfers hang silently (novox/hq: a taken tunnel carries its MTU). A tunnel that set none gets no MTU line. -func TestATakenTunnelCarriesItsMTU(t *testing.T) { - for _, c := range []struct { - mtu int - want string - }{{1380, "MTU = 1380"}, {0, ""}} { - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: c.mtu}, - }, nil) - if c.want != "" && !strings.Contains(config, c.want) || c.want == "" && strings.Contains(config, "MTU") { - t.Errorf("a tunnel of MTU %d gives:\n%s", c.mtu, config) - } - } -} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go deleted file mode 100644 index eb56a43..0000000 --- a/internal/overlay/generator.go +++ /dev/null @@ -1,151 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strconv" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The private network as a module rather than as code beside the module system. -// -// A machine's peer list is derived from every other machine, so it differs on each one and -// changes when any of them changes — there is nothing that could be written in a manifest. So the -// module says its resources are computed, and this computes them. -// -// What that buys, beyond one mechanism instead of two: **a machine is on the private network -// because it was assigned the module.** Before this, every machine with a key and an address was -// on it, and there was no way to say a machine should stay off. -// -// It is worth saying what is *not* here, because networking looks like it should be foundational. -// The host needs none of this. It has an address and a route to the broker before the mesh exists -// — that is the machine's own networking — and the broker's address is carried in the enrolment -// token rather than resolved. So the private network is something the mesh installs on top, like -// anything else, and a node without it is an ordinary node that nothing reaches directly. - -// **A resolver's data went the same way as the names.** Two constants used to sit here — a -// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` -// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that -// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and -// requires Addressing, since the file is made of the mesh's addresses and means nothing off the -// network. A requirement nothing provides is refused at resolution, so leaving the names here -// would only have documented a mechanism that does not exist. - -// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and -// nothing else used to be what a machine was assigned, so that "get the network working" was one -// word and a second VPN could be chosen by assigning it instead. The mesh has one private network, -// every machine is on it, and the bundle was a second name for this module that every machine -// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its -// place, which is all the bundle ever did. - -// Generator answers what one node's network configuration is. -type Generator struct { - nodes []Node - graph Graph - // keyPath is where each node keeps the private half it generated. Named rather than carried: - // the mesh has never seen it and never will. - keyPath string - // No registry. Being on the network is still what grants a machine the right to pull from the - // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's - // module's: the private network writes nothing into it, and that module states the registry - // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR - // 0222, issue 190). -} - -// From builds a generator over the machines that are part of the network. -// -// The nodes given are the ones assigned the module — not every node the mesh knows. A machine -// that was never given it is absent from everybody's peer list and from the names, which is what -// "not on the network" has to mean. -func From(nodes []Node, cidr, keyPath string) (*Generator, error) { - graph, err := Compute(nodes, cidr) - if err != nil { - return nil, err - } - return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil -} - -// Resources is one node's interface, peers and names. -func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { - peers, part := g.graph[node] - if !part { - // Assigned and not yet placed on the network. Ordinary and brief, so it is an answer - // rather than an error. - return nil, false, nil - } - var self Node - for _, n := range g.nodes { - if n.Name == node { - self = n - } - } - - raw, err := Declaration(self, peers, g.keyPath) - if err != nil { - return nil, false, err - } - var parsed struct { - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &parsed); err != nil { - return nil, false, err - } - return parsed.Resources, true, nil -} - -// Nodes are the machines this generator was built over, so a caller can say who is on the network. -func (g *Generator) Nodes() []Node { return g.nodes } - -// Graph is the peer list per node, for showing. -func (g *Generator) Graph() Graph { return g.graph } - -// Empty is a network nobody is on. -// -// A mesh where no machine was given the module. Legitimate rather than broken — every node still -// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for -// everyone instead of refusing for want of a hub. -func Empty() *Generator { return &Generator{graph: Graph{}} } - -// Listens is the port this node accepts the private network on, which only a hub has. -// -// **A fact about this machine's place in the mesh, not about the module.** Every machine on the -// network runs the same module; a hub is dialled by every node at other sites and needs its port -// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in -// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine -// it would get wrong is the one facing the public internet, which is the machine that most needs -// filtering. -// -// The port is the one in the endpoint, which is also where the interface takes its ListenPort -// from. One source, so a rule set cannot open a port the interface is not on. -func (g *Generator) Listens(node string) ([]catalogue.Listening, error) { - for _, n := range g.nodes { - if n.Name != node { - continue - } - if !n.Reachable() { - // It dials out and nothing dials it. Opening a port here would be opening one on a - // machine nothing connects to, which is not harmless — it is a rule with no source - // that somebody later has to work out the reason for. - return nil, nil - } - port := portOf(n.Endpoint) - if port == "" { - return nil, fmt.Errorf( - "%s is reachable at %q and no port can be read from it, so what it must accept "+ - "the private network on is unknown", node, n.Endpoint) - } - number, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port) - } - return []catalogue.Listening{{ - Port: number, Protocol: "udp", From: catalogue.FromEverywhere, - // From everywhere, and deliberately: a node at another site is not on the private - // network until this port lets it on, so restricting this to the mesh would be a - // rule that can never be satisfied by the thing it exists for. - Why: "the private network — a node at another site has no other way in", - }}, nil - } - return nil, nil -} diff --git a/internal/overlay/generator_test.go b/internal/overlay/generator_test.go deleted file mode 100644 index 569322b..0000000 --- a/internal/overlay/generator_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package overlay - -import ( - "testing" -) - -// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The -// private network writes nothing into either — being on it still grants the right to pull from the -// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself. -func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) { - nodes := []Node{ - {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"}, - {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"}, - } - g, err := From(nodes, "10.42.0.0/16", "") - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"anchor", "node2"} { - resources, part, err := g.Resources(node) - if err != nil || !part { - t.Fatalf("%s: resources: %v part=%v", node, err, part) - } - for _, r := range resources { - if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" { - t.Errorf("%s: the private network declares the runtime's %v", node, r) - } - } - } -} diff --git a/internal/overlay/graph.go b/internal/overlay/graph.go deleted file mode 100644 index 4be1a43..0000000 --- a/internal/overlay/graph.go +++ /dev/null @@ -1,231 +0,0 @@ -// Package overlay computes the private network every node runs on. -// -// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from -// every node at once, and no node has that. A node computes nothing about the mesh — it generates -// a keypair, publishes the public half, and receives the rest. -// -// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and -// the reason is a property of WireGuard rather than a preference: there is no failover. A more -// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a -// node gets exactly one path to any peer, because two would mean one of them silently swallowing -// traffic. -package overlay - -import ( - "errors" - "fmt" - "sort" - "strings" -) - -// Node is one machine's place on the network, as the mesh holds it. -type Node struct { - Name string - Key string - Endpoint string - Site string - Hub bool - Address string - - // Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the - // mesh has no record of, each known by the public key and the address the found tunnel routed - // to it. Only a hub has any. They stay in its peer list until a node enrols with that key — - // from then on the node is the peer. - Carried []Carried - // TakesOver names the found tunnel this node's private network replaces: its unit is stopped - // and disabled, never flushed, and its configuration kept, before the mesh's interface comes - // up with the found key. Nil on a node that raises the mesh's interface beside whatever it has. - TakesOver *TakeOver -} - -// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a -// node of the mesh. -type Carried struct { - Key string - Address string -} - -// TakeOver is the found tunnel a node's private network takes over, as the host is told it. -type TakeOver struct { - Interface string - Unit string - Config string - // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU. - MTU int - // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, - // even on a node that is not dialable from the hub: a home node's LAN peers dial it there - // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not - // "the hub can dial me", which is Reachable — the two were conflated. - Port int -} - -// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6. -func HostPrefix(address string) string { - if strings.Contains(address, ":") { - return address + "/128" - } - return address + "/32" -} - -// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named -// by the key its packets arrive under. -func CarriedName(key string) string { - short := key - if len(short) > 8 { - short = short[:8] + "…" - } - return "a peer of the tunnel (" + short + ")" -} - -// Reachable reports whether other nodes can dial this one. Declared, never inferred. -func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" } - -// Peer is one entry in a node's peer list. -type Peer struct { - Name string - Key string - // Endpoint is empty when this peer cannot be dialled — it must dial us instead. - Endpoint string - // Allowed is what traffic goes down this tunnel. A single address for a direct peer; the - // whole overlay for the hub, which is what makes it the route of last resort. - Allowed string - // Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep - // the path open from its end, or the peer's first packet arrives at a mapping that has - // already expired. - Keepalive bool - // Why this peer is in the list, for a person reading a generated file and wondering. - Why string -} - -// Graph is every node's peer list. -type Graph map[string][]Peer - -// ErrNoHub means nobody has said which node is the hub. -// -// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and -// answering with "no peers" would look like a working mesh where nothing can reach anything. -var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites") - -// Compute derives every node's peer list. -// -// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and -// not yet been given a place on the network is an ordinary in-between state, and failing the whole -// graph because one node is half-configured would mean no node gets a network. -func Compute(nodes []Node, overlayCIDR string) (Graph, error) { - var hub *Node - usable := make([]Node, 0, len(nodes)) - for i := range nodes { - n := nodes[i] - if n.Key == "" || n.Address == "" { - continue - } - usable = append(usable, n) - if n.Hub { - hub = &usable[len(usable)-1] - } - } - if len(usable) == 0 { - return Graph{}, nil - } - if hub == nil { - return nil, ErrNoHub - } - if !hub.Reachable() { - return nil, fmt.Errorf( - "%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+ - "that must be reachable from wherever the others are", hub.Name) - } - - graph := Graph{} - for _, self := range usable { - var peers []Peer - - // A node may share a site with the hub, and then the hub is one peer rather than two. - // Written before the loop because it changes what that loop may emit: WireGuard takes one - // entry per public key, so a hub appearing twice is a configuration it refuses — and the - // mesh would have produced it silently. The lab found this on the first two machines that - // shared a site with their hub. - hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site - - for _, other := range usable { - if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) { - continue - } - // Two nodes at the same site peer directly — but only if one of them can be - // dialled. If neither can, nobody opens the path, and the direct route is more - // specific than the hub's, so it wins and blackholes. That is this design's own - // stated hazard arriving in it: *a more specific route to a dead endpoint - // blackholes; it does not fall back to the general one.* - // - // Found in the lab with two machines at one site behind no reachable address, which - // is the ordinary shape of a home: they were given each other as peers, neither - // could start, and they could not reach each other at all while both reached the hub - // perfectly. - if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) { - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Keepalive: !self.Reachable(), - Why: "at the same site", - }) - } - } - - if !self.Hub { - // Everything else goes through the hub, including a node that roams. AllowedIPs is - // the whole overlay, so this is the route of last resort — and because direct peers - // above are single addresses, they win on specificity without either being ambiguous. - why := "the hub — everything not at this site" - if hubIsHere { - // One entry doing both jobs: the direct path to a machine that happens to be - // here, and the route to everywhere else. Splitting them would need two entries - // for one key, which is the thing being avoided. - why = "the hub, which is also at this site — everything goes here" - } - peers = append(peers, Peer{ - Name: hub.Name, Key: hub.Key, - Endpoint: hub.Endpoint, - Allowed: overlayCIDR, - Keepalive: !self.Reachable(), - Why: why, - }) - } else { - // The hub holds every node that does not share a site with it, because those nodes - // route through it and it must know where to send the replies. Ones it cannot dial - // will dial it. - enrolled := map[string]bool{} - for _, other := range usable { - enrolled[other.Key] = true - if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) { - continue - } - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Why: "routes through this hub", - }) - } - // And every peer of the tunnel it took over that has not enrolled (novox/hq ADR - // 0105): the same key and the same address the found tunnel had for it, so the - // machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in, - // as it always did. Once a node enrols with that key, the node's entry above is the - // peer, and WireGuard takes one entry per key. - for _, c := range self.Carried { - if enrolled[c.Key] { - continue - } - peers = append(peers, Peer{ - Name: CarriedName(c.Key), Key: c.Key, - Allowed: HostPrefix(c.Address), - Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh", - }) - } - } - - sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) - graph[self.Name] = peers - } - return graph, nil -} diff --git a/internal/overlay/graph_test.go b/internal/overlay/graph_test.go deleted file mode 100644 index eb8c4e1..0000000 --- a/internal/overlay/graph_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package overlay - -import ( - "errors" - "strings" - "testing" -) - -const cidr = "10.42.0.0/16" - -func at(name, site, address, endpoint string, hub bool) Node { - return Node{Name: name, Key: "key-" + name, Site: site, Address: address, - Endpoint: endpoint, Hub: hub} -} - -func peersOf(t *testing.T, g Graph, node string) map[string]Peer { - t.Helper() - out := map[string]Peer{} - for _, p := range g[node] { - out[p.Name] = p - } - return out -} - -func TestEveryNodeReachesTheHub(t *testing.T) { - // The property that makes this a network at all. Without it a node has no route to anything - // it does not share a site with. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"laptop", "home"} { - hub, ok := peersOf(t, g, node)["anchor"] - if !ok { - t.Fatalf("%s has no route to the hub", node) - } - if hub.Allowed != cidr { - t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+ - "not a route of last resort", node, hub.Allowed) - } - } -} - -func TestNodesAtOneSitePeerDirectly(t *testing.T) { - // Machines that share a site have a path that does not need the hub, and using it keeps their - // traffic off a link that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "192.0.2.3:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - direct, ok := peersOf(t, g, "desk")["server"] - if !ok { - t.Fatal("two machines at one site do not peer directly") - } - if direct.Allowed != "10.42.0.3/32" { - t.Errorf("the direct peer allows %s; a single address is what makes it win on "+ - "specificity over the hub's whole-overlay route", direct.Allowed) - } -} - -func TestARoamingNodeGetsExactlyOnePath(t *testing.T) { - // Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to - // a dead endpoint blackholes rather than falling back, so two paths would mean one of them - // silently swallowing traffic. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("other", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if got := len(g["laptop"]); got != 1 { - t.Fatalf("a roaming node has %d peers; it gets exactly one path", got) - } - if g["laptop"][0].Name != "anchor" { - t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name) - } -} - -func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) { - // An empty site is not a site. Nodes that roam have no shared location, and treating "" as - // one would have every roaming machine try to dial every other, none of which can be dialled. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("phone", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "laptop")["phone"]; ok { - t.Error("two nodes with no site peered as though they shared one") - } -} - -func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) { - // Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet - // arrives at a mapping that has already expired. On the reachable side it is pointless - // traffic for ever. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, g, "laptop")["anchor"].Keepalive { - t.Error("a node that cannot be dialled does not keep its path open") - } - if peersOf(t, g, "anchor")["laptop"].Keepalive { - t.Error("a reachable node sends keepalives it does not need") - } - - // And between two direct peers at one site, where whether to keep the path open depends on - // which end you are. Checked here because the hub's own peer list happens not to set the - // field at all, so asserting only against the hub tests an absence rather than the rule. - same, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, same, "server")["desk"].Keepalive { - t.Error("the peer that cannot be dialled does not keep the path open") - } - if peersOf(t, same, "desk")["server"].Keepalive { - t.Error("the peer that can be dialled sends keepalives it does not need") - } -} - -func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) { - // The replies have to get back. A hub that does not hold a peer cannot answer it. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - hub := peersOf(t, g, "anchor") - for _, want := range []string{"laptop", "home"} { - if _, ok := hub[want]; !ok { - t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want) - } - } -} - -func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { - // A node that has enrolled and not yet been given an address is an ordinary in-between state. - // Failing the whole graph over it would mean no node gets a network because one is half done. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - {Name: "newcomer", Key: "", Address: ""}, - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := g["newcomer"]; ok { - t.Error("a node with no key or address was given a peer list") - } - if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok { - t.Error("a node with no key was put in somebody's peer list") - } -} - -func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { - // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being - // emitted twice — once as a direct peer and once as the route of last resort — producing a - // configuration the interface refuses, from a mesh that thought it had succeeded. - // - // Found in the lab on the first two machines that shared a site with their hub, which is the - // ordinary case for a small mesh and was in none of the tests above. - g, err := Compute([]Node{ - at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true), - at("laptop", "lab", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - seen := map[string]int{} - for _, p := range g["laptop"] { - seen[p.Key]++ - } - for key, count := range seen { - if count > 1 { - t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count) - } - } - - if len(g["laptop"]) != 1 { - t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"])) - } - // And that single entry has to carry everything, or the node keeps a direct path to the hub - // and loses its route to everywhere else. - if got := g["laptop"][0].Allowed; got != cidr { - t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+ - "resort, so it carries the whole overlay", got) - } - // And nobody peers with themselves, here or in a site of two dialable nodes. - other, err := Compute([]Node{ - at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for _, graph := range []Graph{g, other} { - for node, peers := range graph { - for _, p := range peers { - if p.Name == node { - t.Errorf("%s peers with itself", node) - } - } - } - } -} - -func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) { - // Nobody would open the path, and the direct route is more specific than the hub's — so it - // wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each - // other, which is the worst arrangement available: it looks configured and is not. - // - // This is the design's own warning arriving in its implementation, and the lab found it with - // two machines at one site behind no reachable address — the ordinary shape of a house. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - if _, ok := peersOf(t, g, "desk")["server"]; ok { - t.Error("two nodes that neither can be dialled were peered directly; neither could open " + - "the path and the route is more specific than the hub's, so it blackholes") - } - // And they must still be able to reach each other — through the hub. - if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr { - t.Errorf("desk's route to everything else allows %s", hub.Allowed) - } -} - -func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) { - // The other side of it: if either end can be dialled, the path can be opened, and using it - // keeps their traffic off a hub that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "server")["desk"]; !ok { - t.Error("a node did not peer with a reachable neighbour at its own site") - } - if _, ok := peersOf(t, g, "desk")["server"]; !ok { - t.Error("the reachable node did not hold its unreachable neighbour, so replies have " + - "nowhere to go") - } -} - -// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel -// had, under the key and at the address the peer knows, until a node enrols with that key. -func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) { - hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true) - hub.Carried = []Carried{ - {Key: "key-home", Address: "192.0.2.2"}, - {Key: "key-workstation", Address: "192.0.2.3"}, - } - // The machine behind key-home enrolled: it is a node now, at the address it kept. - home := at("home", "house", "192.0.2.2", "", false) - home.Key = "key-home" - - g, err := Compute([]Node{hub, home}, "192.0.2.0/24") - if err != nil { - t.Fatal(err) - } - peers := peersOf(t, g, "anchor") - carried, ok := peers[CarriedName("key-workstation")] - if !ok { - t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"]) - } - if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" { - t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried) - } - if _, twice := peers[CarriedName("key-home")]; twice { - t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key") - } - if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" { - t.Errorf("the enrolled peer is not the node it became: %+v", node) - } - // Carried peers are the hub's business only: a spoke routes everything through the hub. - if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked { - t.Error("a carried peer appeared in a spoke's peer list") - } -} - -// A mesh whose hub cannot carry the paths between sites is refused, never answered as an empty graph: with no hub there -// is no path between sites, and a hub with no endpoint is a peer every node is given and none can reach. Either would -// look configured and be silent, which is how the old arrangement failed when nobody knew the address convention. -func TestAHubThatCannotCarryThePathsIsRefused(t *testing.T) { - for _, c := range []struct { - name string - nodes []Node - says func(error) bool - }{ - {"no hub", []Node{at("a", "", "10.42.0.1", "", false), at("b", "", "10.42.0.2", "", false)}, - func(err error) bool { return errors.Is(err, ErrNoHub) }}, - {"a hub with no endpoint", []Node{at("anchor", "datacentre", "10.42.0.1", "", true), at("laptop", "", "10.42.0.2", "", false)}, - func(err error) bool { return err != nil && strings.Contains(err.Error(), "must be reachable") }}, - } { - if _, err := Compute(c.nodes, cidr); !c.says(err) { - t.Errorf("%s: %v", c.name, err) - } - } -} diff --git a/internal/overlay/names.go b/internal/overlay/names.go deleted file mode 100644 index 3aba422..0000000 --- a/internal/overlay/names.go +++ /dev/null @@ -1,30 +0,0 @@ -package overlay - -import ( - sdk "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// The private network's names and the shape of its module stay in mesh-sdk's overlay package, which more parts read -// (novox/hq ADR 0593 §1); the generator of each node's network configuration is the node-engine's own, here. These -// are those names, so the generator and the composition read one package. -const ( - Requirement = sdk.Requirement - Name = sdk.Name - Addressing = sdk.Addressing - TheNetwork = sdk.TheNetwork - SuffixVar = sdk.SuffixVar - DefaultSuffix = sdk.DefaultSuffix - Interface = sdk.Interface - ConfigPath = sdk.ConfigPath - Unit = sdk.Unit - DefaultKeyPath = sdk.DefaultKeyPath -) - -// Manifest is the module the mesh provides for itself (sdk.Manifest). -func Manifest() map[string]any { return sdk.Manifest() } - -// Suffix is the internal names' suffix (sdk.Suffix). -func Suffix() string { return sdk.Suffix() } - -// InternalName is a node's internal name (sdk.InternalName). -func InternalName(node string) string { return sdk.InternalName(node) } diff --git a/internal/overlay/opens_test.go b/internal/overlay/opens_test.go deleted file mode 100644 index 7abf1bd..0000000 --- a/internal/overlay/opens_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package overlay - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -func networkOf(t *testing.T, nodes []Node) *Generator { - t.Helper() - made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key") - if err != nil { - t.Fatal(err) - } - return made -} - -// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a -// hub dials out and needs nothing open at all. -// -// They are the same module, which is why a static field in a manifest cannot say it — and the -// machine it gets wrong is the one facing the public internet, which is the machine that most -// needs filtering. -func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - {Name: "laptop", Key: "b", Site: "one"}, - }) - - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 { - t.Fatalf("the machine every other one dials opens %d ports", len(opens)) - } - if opens[0].Port != 51820 || opens[0].At() != "udp" { - t.Fatalf("the port is not the one the interface listens on: %+v", opens[0]) - } - // From everywhere, and deliberately: a node at another site is not on the private network - // until this port lets it on, so restricting it to the mesh would be a rule that can never be - // satisfied by the thing it exists for. - if opens[0].From != catalogue.FromEverywhere { - t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0]) - } - if opens[0].Why == "" { - t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable") - } - - // And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no - // reason is one somebody later has to work out the reason for. - quiet, err := network.Listens("laptop") - if err != nil { - t.Fatal(err) - } - if len(quiet) != 0 { - t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet)) - } -} - -// The port comes from the endpoint, which is where the interface takes its ListenPort from. One -// source, so a rule set cannot open a port the interface is not on. -func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true}, - }) - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 || opens[0].Port != 60000 { - t.Fatalf("the rule set would open a port the interface is not on: %+v", opens) - } - - written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1", - Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(written), "ListenPort = 60000") { - t.Fatalf("the interface and the rule set disagree about the port:\n%s", written) - } -} - -// An endpoint with no port is refused rather than treated as a machine that opens nothing. -// -// A generator that cannot say what a machine opens is not one that says it opens nothing, and -// closing a port on the evidence of a failure to look is how a machine is severed by a fault -// somewhere else entirely. -func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true}, - }) - if _, err := network.Listens("anchor"); err == nil { - t.Fatal("a machine whose port could not be read was treated as opening nothing") - } -} - -// A machine the network has never heard of opens nothing, and that is an answer rather than an -// error: a node assigned the module before it is placed is in exactly that state. -func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - }) - opens, err := network.Listens("a-stranger") - if err != nil { - t.Fatal(err) - } - if len(opens) != 0 { - t.Fatalf("a machine not on the network opened %d port(s)", len(opens)) - } -} -- 2.54.0 From 308dbb94b8df3970a870cf62133310a9e4831b1a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:45:43 +0100 Subject: [PATCH 32/73] =?UTF-8?q?Node-engine:=20seat-list=20and=20seat-lis?= =?UTF-8?q?t-again=20answer=20every=20operator's=20session=20and=20the=20h?= =?UTF-8?q?ealers,=20no=20other=20caller=20(ADR=200636=20=C2=A77;=20issue?= =?UTF-8?q?=201966)=20Claude-Session:=20https://claude.ai/code/session=5F0?= =?UTF-8?q?1NcbgfFSnn24M3GcqttoU7i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/link/access_act_test.go | 4 ++-- internal/link/engineseat.go | 2 +- internal/link/handacts.go | 17 +++++++++++++++-- internal/link/handover_test.go | 10 +++++++++- internal/link/run.go | 2 +- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/link/access_act_test.go b/internal/link/access_act_test.go index 17278bb..32baa52 100644 --- a/internal/link/access_act_test.go +++ b/internal/link/access_act_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" ) diff --git a/internal/link/engineseat.go b/internal/link/engineseat.go index 4d47ace..ad7ca02 100644 --- a/internal/link/engineseat.go +++ b/internal/link/engineseat.go @@ -10,7 +10,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/nats-io/nats.go" diff --git a/internal/link/handacts.go b/internal/link/handacts.go index bb0cf84..6592603 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -13,9 +13,9 @@ import ( "sync" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -58,6 +58,13 @@ const ( ActSeatListAgain = "seat-list-again" ) +// SeatListCallers are who may call the engine's two seat-list verbs: every operator's session and the healers (novox/hq +// ADR 0636 §7, the node-engine seat's definition: the operator and the healers). +var SeatListCallers = stdio.VerbCallers{ + ActSeatList: {stdio.AnyPerson, "mesh-healers"}, + ActSeatListAgain: {stdio.AnyPerson, "mesh-healers"}, +} + // The states of an act: asked, done and refused are also the seat's events. const ( HandAsked = nodeengine.EventAsked @@ -228,11 +235,17 @@ func (h *HandActs) Call(c EngineCall) (any, error) { } return h.Compose(arg("module")) case ActSeatList: + if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil { + return nil, err + } if h.SeatList == nil { return nil, errors.New("this engine reads no seat list yet: its node agent has not started") } return h.SeatList() case ActSeatListAgain: + if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil { + return nil, err + } if h.SeatListAgain == nil { return nil, errors.New("this engine reads no seat list yet: its node agent has not started") } diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 6dd537a..54665ae 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -264,7 +264,9 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { Composition: func(module, digest string, diff bool) (any, error) { return map[string]any{"module": module, "digest": digest, "diff": diff}, nil }, - Compose: func(module string) (any, error) { return map[string]any{"composed": true, "retried": module}, nil }, + Compose: func(module string) (any, error) { return map[string]any{"composed": true, "retried": module}, nil }, + SeatList: func() (any, error) { return map[string]any{"held": true}, nil }, + SeatListAgain: func() (any, error) { return map[string]any{"read": true}, nil }, // ADR 0618 §3: what this engine states it reads, and when it last stated it. ManifestReads: func() ([]string, time.Time) { return []string{"form:a", "seat:b"}, time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC) @@ -299,6 +301,12 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`}, {"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`}, {"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`}, + // ADR 0636 §7: the seat-list verbs answer every operator's session and the healers, no other module. + {"seat-list", "mesh.seat.node-engine.call.seat-list.n1.person~jochen", `"held":true`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.person~jochen", `"read":true`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~mesh-healers", `"read":true`}, + {"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `not on the holder's caller list for seat-list-again`}, + {"seat-list-again", EngineToolSubject("seat-list-again", "n1"), `no caller the tool runner proved`}, {"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`}, } { subject, body, ok := strings.Cut(row.subject, " ") diff --git a/internal/link/run.go b/internal/link/run.go index 96c9bd8..fd13c56 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" ) // Membership is what a node needs to reach its mesh again, held by the caller. -- 2.54.0 From 84e50beb65e72b9b5446bc33fd89703a363fb332 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:45:43 +0100 Subject: [PATCH 33/73] =?UTF-8?q?Node-engine:=20the=20private=20network's?= =?UTF-8?q?=20names=20and=20generator=20come=20from=20mesh-wireguard's=20o?= =?UTF-8?q?verlay=20package;=20the=20host's=20internal/overlay=20is=20dele?= =?UTF-8?q?ted,=20mesh-sdk's=20go/overlay=20has=20no=20importer=20left=20h?= =?UTF-8?q?ere;=20contracts=20imports=20follow=20the=20domain=20folders=20?= =?UTF-8?q?(ADR=200636=20=C2=A76,=20ADR=200634;=20issue=201966)=20Claude-S?= =?UTF-8?q?ession:=20https://claude.ai/code/session=5F01NcbgfFSnn24M3Gcqtt?= =?UTF-8?q?oU7i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-compose/main_test.go | 5 +++-- cmd/mesh-host/authority_test.go | 4 ++-- cmd/mesh-host/engineseat.go | 2 +- cmd/mesh-host/libraries_test.go | 4 ++-- cmd/mesh-host/nodeagent.go | 2 +- cmd/mesh-host/trust.go | 4 ++-- cmd/mesh-host/whatruns_test.go | 2 +- go.mod | 9 ++++----- internal/compose/composed.go | 2 +- internal/compose/composed_test.go | 2 +- internal/compose/credentials_test.go | 2 +- internal/compose/fixture_test.go | 2 +- internal/compose/frombus/answer_rows_test.go | 2 +- internal/compose/frombus/frombus.go | 2 +- internal/compose/frombus/frombus_test.go | 2 +- internal/compose/network.go | 2 +- internal/compose/network_test.go | 2 +- internal/compose/node_test.go | 2 +- internal/compose/one_machine_alone_test.go | 2 +- internal/compose/rendering.go | 2 +- internal/compose/rendering_test.go | 2 +- internal/compose/resolve_test.go | 2 +- internal/compose/rest_test.go | 2 +- internal/selfcompose/selfcompose.go | 2 +- internal/selfcompose/selfcompose_test.go | 2 +- 25 files changed, 33 insertions(+), 33 deletions(-) diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index 83a5faf..0b7de35 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -280,7 +280,8 @@ func ownPackages(t *testing.T) map[string][]string { } // 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard -// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or +// library, mesh-sdk, the private network's overlay package (mesh-wireguard's own pure package: names, graph and +// generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or // another process. func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"} @@ -291,7 +292,7 @@ func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { for path, imports := range own { for _, imp := range imports { std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") - if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { + if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp) } for _, f := range forbidden { diff --git a/cmd/mesh-host/authority_test.go b/cmd/mesh-host/authority_test.go index 0ff93a1..7ff84f8 100644 --- a/cmd/mesh-host/authority_test.go +++ b/cmd/mesh-host/authority_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-contracts/go/seats/meshvault" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/identity" ) diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index 9d1f309..31c7731 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -14,8 +14,8 @@ import ( "time" nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" stdio "git.novox.be/novox/mesh-sdk/go" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/apply" diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go index da6902a..ddb3d07 100644 --- a/cmd/mesh-host/libraries_test.go +++ b/cmd/mesh-host/libraries_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient" - oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 66af382..98d2e11 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -14,9 +14,9 @@ import ( "sync/atomic" "time" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/compose/frombus" diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 3d3b963..7ee9f2a 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -13,9 +13,9 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-contracts/go/seats/meshvault" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 05d809f..c756df7 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/liveness" diff --git a/go.mod b/go.mod index 17392cf..17ac313 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 @@ -10,16 +11,14 @@ require ( golang.org/x/sys v0.48.0 ) -require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect - require ( git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-20261105000000-000000000000 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect diff --git a/internal/compose/composed.go b/internal/compose/composed.go index 5d5df71..024f287 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -8,11 +8,11 @@ import ( "sort" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index c66878b..09dacb5 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } diff --git a/internal/compose/credentials_test.go b/internal/compose/credentials_test.go index 0ab188b..50ddfc6 100644 --- a/internal/compose/credentials_test.go +++ b/internal/compose/credentials_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // secretValue is a value the Input carries that must never appear in anything the composition says. diff --git a/internal/compose/fixture_test.go b/internal/compose/fixture_test.go index 1766852..69a0ec7 100644 --- a/internal/compose/fixture_test.go +++ b/internal/compose/fixture_test.go @@ -8,8 +8,8 @@ import ( "reflect" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it. diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 05a7fe9..113c4f0 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -10,11 +10,11 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only; diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index a7e887c..aafa499 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -41,12 +41,12 @@ import ( "sort" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" "github.com/novox/mesh-host/internal/takein" ) diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 3f4e889..f9e8467 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -18,10 +18,10 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" - "github.com/novox/mesh-host/internal/overlay" ) // bus is a Reader over values held in memory, counting whose config values were read. diff --git a/internal/compose/network.go b/internal/compose/network.go index 4e11abf..1ba2dfd 100644 --- a/internal/compose/network.go +++ b/internal/compose/network.go @@ -8,9 +8,9 @@ import ( "strconv" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // The private network: who is on it, where, and what they are called. Ported from mesh-controller diff --git a/internal/compose/network_test.go b/internal/compose/network_test.go index 2f924f9..3b1ca59 100644 --- a/internal/compose/network_test.go +++ b/internal/compose/network_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in diff --git a/internal/compose/node_test.go b/internal/compose/node_test.go index 1d2c67b..6735f95 100644 --- a/internal/compose/node_test.go +++ b/internal/compose/node_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) func resourceIDs(d Declared) []string { diff --git a/internal/compose/one_machine_alone_test.go b/internal/compose/one_machine_alone_test.go index 3c49abb..f05accb 100644 --- a/internal/compose/one_machine_alone_test.go +++ b/internal/compose/one_machine_alone_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index 7d06652..d0b2afe 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -7,9 +7,9 @@ import ( "sort" "strings" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go index 8c50b5b..78d7a79 100644 --- a/internal/compose/rendering_test.go +++ b/internal/compose/rendering_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-host/internal/overlay" ) // aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide). diff --git a/internal/compose/resolve_test.go b/internal/compose/resolve_test.go index 0696afb..d361b8c 100644 --- a/internal/compose/resolve_test.go +++ b/internal/compose/resolve_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the diff --git a/internal/compose/rest_test.go b/internal/compose/rest_test.go index f771c10..d0b0d2b 100644 --- a/internal/compose/rest_test.go +++ b/internal/compose/rest_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/novox/mesh-host/internal/compose/resolution" - "github.com/novox/mesh-host/internal/overlay" ) // objects and files are the provider and consumer of mesh-controller's facts_test.go aMeshWithSecrets (cmd/mesh-controller at diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 0bc5d21..109ef20 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -28,9 +28,9 @@ import ( "sync/atomic" "time" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/resolution" diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 542667a..dfb90a8 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -18,8 +18,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" -- 2.54.0 From dcbdc70919e6651dbf9941a82958196ebdbb4ce2 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:48:20 +0100 Subject: [PATCH 34/73] =?UTF-8?q?Node=20composer:=20while=20the=20seat=20l?= =?UTF-8?q?ist=20is=20unread=20the=20node's=20own=20composition=20is=20hel?= =?UTF-8?q?d=20too,=20whole,=20under=20the=20scope=20node;=20only=20what?= =?UTF-8?q?=20runs=20and=20the=20bootstrapped=20modules=20go=20on=20(ADR?= =?UTF-8?q?=200636=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/selfcompose/selfcompose.go | 23 +++++++++++++++-------- internal/selfcompose/selfcompose_test.go | 20 ++++++++++++++------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 109ef20..302f142 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -1769,10 +1769,14 @@ func mapValues(m map[string]delivery.Composed) []delivery.Composed { // WaitsForTheSeatList is why a module is held while the node has not read the seat list. const WaitsForTheSeatList = "the node has not read the seat list, so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" +// WaitsForTheSeatListOwn is why the node's own composition is held: it folds modules and accounts whose seats the node +// cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1). +const WaitsForTheSeatListOwn = "the node has not read the seat list, so it composes nothing new of itself and keeps what runs (novox/hq ADR 0636 §1)" + // holdUntilListed takes out of compositions every module's composition that is new (no running composition has its // digest) while the reader may not act on its seats, and names the module in held: it keeps running what it runs, and -// the pass after the list was read hands it. The node's own composition and a module whose composition runs already are -// left as they are. +// the pass after the list was read hands it. The node's own composition is held the same way, whatever it folds, under +// the scope agent.NodeModule; a composition that runs already is left as it is. func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions map[string]delivery.Composed, held map[string]string, running []delivery.Composed) map[string]string { if c.env.SeatList == nil { return held @@ -1784,21 +1788,24 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m } } for key, comp := range compositions { - if comp.Module == "" { - continue - } if dig, err := delivery.ComposedDigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { continue } - if c.env.SeatList.MayAct(c.seatsOf(ctx, r, comp.Module)...) { + module, why := comp.Module, WaitsForTheSeatList + if module == "" { // the node's own: no seat to judge, so while the list is unread it waits whole + if c.env.SeatList.MayAct() { + continue + } + module, why = agent.NodeModule, WaitsForTheSeatListOwn + } else if c.env.SeatList.MayAct(c.seatsOf(ctx, r, module)...) { continue } delete(compositions, key) if held == nil { held = map[string]string{} } - if _, already := held[comp.Module]; !already { - held[comp.Module] = WaitsForTheSeatList + if _, already := held[module]; !already { + held[module] = why } } return held diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index dfb90a8..a33a7e5 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -1812,17 +1812,25 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { t.Errorf("the node handed %s before it read the seat list", k) } } - if !handed["n1.node"] { - t.Errorf("the node's own composition was held with the module: %v", handed) + // The node's own composition folds modules and accounts it cannot judge before the read: held whole (the ruling of + // ADR 0636 §1), under the scope agent.NodeModule. + mu.Lock() + ownWhy := heldBy[agent.NodeModule] + mu.Unlock() + if ownWhy != WaitsForTheSeatListOwn || handed["n1.node"] { + t.Errorf("the node's own composition is held with %q and handed=%v, want %q and not handed: %v", ownWhy, handed["n1.node"], WaitsForTheSeatListOwn, handed) } - // The reader reads the list and says so: the module is handed, with no timer. + // The reader reads the list and says so: the module and the node's own are handed, with no timer. g.open.Store(true) changed <- struct{}{} - for { + seen := map[string]bool{} + for !(seen["lemurs"] && seen["node"]) { u := next(t, out) - if strings.Contains(u.Key, "lemurs") && !u.Deleted { - break + if u.Deleted { + continue } + seen["lemurs"] = seen["lemurs"] || strings.Contains(u.Key, "lemurs") + seen["node"] = seen["node"] || u.Key == "n1.node" } mu.Lock() still := heldBy["lemurs"] -- 2.54.0 From df44d21c82b21f1f8bbea01af5f9c5d554d4bb9e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:53:06 +0100 Subject: [PATCH 35/73] =?UTF-8?q?Node-engine:=20composition=20comparison,?= =?UTF-8?q?=20seals=20and=20differing=20paths=20are=20the=20engine's=20own?= =?UTF-8?q?=20package,=20internal/compositions,=20with=20the=20tests=20mov?= =?UTF-8?q?ed=20from=20mesh-sdk's=20delivery=20(H-1,=20ADR=200634=20=C2=A7?= =?UTF-8?q?1;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/nodeagent.go | 5 +- internal/agent/agent.go | 11 +- internal/agent/agent_test.go | 9 +- internal/compose/frombus/answer.go | 5 +- internal/compose/frombus/frombus.go | 2 +- internal/compose/frombus/frombus_test.go | 7 +- internal/compositions/answer_paths.go | 137 +++++++ internal/compositions/compare_test.go | 169 +++++++++ internal/compositions/composed_compare.go | 412 ++++++++++++++++++++++ internal/compositions/composed_seals.go | 195 ++++++++++ internal/link/have.go | 5 +- internal/link/have_test.go | 3 +- internal/link/messages.go | 3 +- internal/selfcompose/selfcompose.go | 15 +- internal/selfcompose/selfcompose_test.go | 33 +- 15 files changed, 967 insertions(+), 44 deletions(-) create mode 100644 internal/compositions/answer_paths.go create mode 100644 internal/compositions/compare_test.go create mode 100644 internal/compositions/composed_compare.go create mode 100644 internal/compositions/composed_seals.go diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 98d2e11..30d4c74 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" "sort" @@ -603,7 +604,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus unseal: sealOpener(opts.state), // And what each module's have says it runs sealed: after a restart the agent has applied nothing yet. // A have the bus did not answer for is said as such, never as one holding no seals (issue 1126, 2026-10-28). - held: func(module string) (delivery.Seals, []string, error) { + held: func(module string) (compositions.Seals, []string, error) { if module == agent.NodeModule { got, _, err := session.ReadNodeHaveErr(linked) return got.SealedAt, got.Sealed, err @@ -985,7 +986,7 @@ type composerHooks struct { unseal func(string) ([]byte, error) refused func(why string, held map[string]string) kept func(kept map[string][]string) - held func(module string) (delivery.Seals, []string, error) + held func(module string) (compositions.Seals, []string, error) // vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify. vaultAuthority func() string certify func(ctx context.Context) error diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 1367c9f..198a966 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,6 +17,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/novox/mesh-host/internal/compositions" "slices" "sort" "strings" @@ -68,7 +69,7 @@ type Have struct { // Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by // slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at // its own slot alone (issue 1126, C1). - Sealed delivery.Seals + Sealed compositions.Seals // PullWait is, on a failed have, the pull from a store its apply failed on and waits on the holder of (issue 1966 // ENG-9, delivery.Have.PullWait): read through the have, as node_haves shows it. PullWait *delivery.PullWait @@ -702,15 +703,15 @@ func (a *Agent) KeptNow(ctx context.Context, kept map[string][]string) { } } -func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) delivery.Seals { +func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) compositions.Seals { a.mu.Lock() if c, ok := a.applied[module]; ok && c.Err == nil && c.Dig == composed { a.mu.Unlock() - return delivery.SealsIn(c.C) + return compositions.SealsIn(c.C) } if c, ok := a.compositionFor(module, fp); ok && c.Err == nil && c.Dig == composed { a.mu.Unlock() - return delivery.SealsIn(c.C) + return compositions.SealsIn(c.C) } a.mu.Unlock() if h, ok := a.env.Haves.Read(ctx, module); ok && h.Composed == composed { @@ -1771,7 +1772,7 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp // for a yes nobody needs (issue 774, 2026-10-31: four providers stayed waiting-yes once their consumers composed). if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(delivery.PhaseWaitingYes) { // Its sealed values only when they are of what runs; else writeHave reads them (sealedOf). - var sealed delivery.Seals + var sealed compositions.Seals if h.Composed == r.Composed { sealed = h.Sealed } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 92137b6..4431c3b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/novox/mesh-host/internal/compositions" "maps" "slices" "strings" @@ -1583,7 +1584,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // agent's start (the switch and every delivery of the node-engine restart it), so what runs is what its have said. idleFP, idleDig := fpOf("c"), "sha256:"+strings.Repeat("9", 64) b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(delivery.PhaseRunning), - Sealed: delivery.Seals{"idle.key#sealed": `{"box":"idle"}`}}) + Sealed: compositions.Seals{"idle.key#sealed": `{"box":"idle"}`}}) want("idle", idleFP, "") eventually(t, "idle's want was not taken", func() bool { b.ag.mu.Lock() @@ -1742,7 +1743,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3). recFP, recDig := fpOf("d"), "sha256:"+strings.Repeat("8", 64) b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(delivery.PhaseRunning), - Sealed: delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}}) + Sealed: compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}}) rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64))) b.comps.put(dk2, draw2) b.comps.put(rk, rraw) @@ -1751,9 +1752,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( applied := len(b.appliedList()) for _, row := range []struct { module, fp, composed, waits string - sealed delivery.Seals + sealed compositions.Seals alone bool - }{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} { + }{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} { eventually(t, row.module+"'s move did not wait for a yes", func() bool { h := b.haves.last(row.module) return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == row.waits diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index 79be25e..14b1b93 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "maps" "regexp" "slices" @@ -18,7 +19,7 @@ import ( // Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with // s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as -// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes +// for its own composition), compared with what it runs (compositions.CompareComposed). It applies nothing and writes // nothing; the proposal and the revision are the candidate's, said back in the answer. // // The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the @@ -115,7 +116,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st } running = judged } - cmp := delivery.CompareComposedWith(composed, running, s.SameValue) + cmp := compositions.CompareComposedWith(composed, running, s.SameValue) // The answer rides in the node's have key: its shown bodies are bounded, the rest named only. budget := MaxShown for i := range cmp.Differs { diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index aafa499..e793a40 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -152,7 +152,7 @@ type Scope struct { // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). HeldToday map[string]string // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own - // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them + // key), so a differing resource shows a value sealed again as such (compositions.CompareComposedWith). Nil shows them // as differing. SameValue func(a, b string) (same, known bool) // Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index f9e8467..0ead30e 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "os" "reflect" "slices" @@ -293,7 +294,7 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { + if c := compositions.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { t.Errorf("the candidate equal to the placement: %+v", c) } moved := mk().placements["anchor"] @@ -305,10 +306,10 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { + if c := compositions.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name) } - if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { + if c := compositions.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { t.Errorf("nothing runs: %+v", c) } diff --git a/internal/compositions/answer_paths.go b/internal/compositions/answer_paths.go new file mode 100644 index 0000000..a3217a4 --- /dev/null +++ b/internal/compositions/answer_paths.go @@ -0,0 +1,137 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "git.novox.be/novox/mesh-sdk/go/delivery" + "sort" + "strconv" + "strings" +) + +// DifferingPaths is every JSON path at which two bodies of one resource differ, sorted (issue 1126, T1: the shadow +// shows its work): object keys and list places joined by dots (`content.url`, `links.0`), a string that holds a JSON +// object or list read as that JSON and walked on (a runtime's config carried as text), a key or place one side alone +// holds named as it is. A key a secret could be under (secretWords) is named and not walked into. A body that is not +// JSON differs at "" (the whole), and a resource one side alone holds has none. At most MaxPaths: the last then says how many more there are. A path names keys +// alone, never a value, so it holds no secret. +func DifferingPaths(composed, running json.RawMessage) []string { + if composed == nil || running == nil { + return nil // one side alone holds it: its why says so + } + var a, b any + okA := unmarshalNumbers(composed, &a) == nil + okB := unmarshalNumbers(running, &b) == nil + if !okA || !okB { + if bytes.Equal(composed, running) { + return nil + } + return []string{""} + } + var out []string + walkDiffering(a, b, "", &out) + sort.Strings(out) + if len(out) > delivery.MaxPaths { + more := len(out) - (delivery.MaxPaths - 1) + out = append(out[:delivery.MaxPaths-1], "… "+strconv.Itoa(more)+" more") + } + return out +} + +func unmarshalNumbers(raw []byte, into *any) error { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + return d.Decode(into) +} + +// asJSON is a string holding a JSON object or list read as that value; v itself otherwise. +func asJSON(v any) any { + s, ok := v.(string) + if !ok { + return v + } + t := strings.TrimSpace(s) + if t == "" || (t[0] != '{' && t[0] != '[') { + return v + } + var parsed any + if unmarshalNumbers([]byte(t), &parsed) != nil { + return v + } + return parsed +} + +func join(at, key string) string { + if at == "" { + return key + } + return at + "." + key +} + +func walkDiffering(a, b any, at string, out *[]string) { + a, b = asJSON(a), asJSON(b) + ma, isMA := a.(map[string]any) + mb, isMB := b.(map[string]any) + if isMA && isMB { + keys := map[string]bool{} + for k := range ma { + keys[k] = true + } + for k := range mb { + keys[k] = true + } + for k := range keys { + x, inA := ma[k] + y, inB := mb[k] + switch { + case !inA || !inB: + *out = append(*out, join(at, k)) + case secretKey(k): + // A file's secrets (SecretsField) are named entry by entry, each entry never entered; any other + // object under a secret key, a vault box included, is one path. + sx, isSX := x.(map[string]any) + sy, isSY := y.(map[string]any) + if k != SecretsField || !isSX || !isSY { + if !sameAny(x, y) { + *out = append(*out, join(at, k)) + } + continue + } + for name := range sx { + if v, in := sy[name]; !in || !sameAny(sx[name], v) { + *out = append(*out, join(join(at, k), name)) + } + } + for name := range sy { + if _, in := sx[name]; !in { + *out = append(*out, join(join(at, k), name)) + } + } + default: + walkDiffering(x, y, join(at, k), out) + } + } + return + } + la, isLA := a.([]any) + lb, isLB := b.([]any) + if isLA && isLB { + for i := range max(len(la), len(lb)) { + if i >= len(la) || i >= len(lb) { + *out = append(*out, join(at, strconv.Itoa(i))) + continue + } + walkDiffering(la[i], lb[i], join(at, strconv.Itoa(i)), out) + } + return + } + if !sameAny(a, b) { + *out = append(*out, at) + } +} + +func sameAny(a, b any) bool { + x, errA := json.Marshal(a) + y, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(x, y) +} diff --git a/internal/compositions/compare_test.go b/internal/compositions/compare_test.go new file mode 100644 index 0000000..fa5bc42 --- /dev/null +++ b/internal/compositions/compare_test.go @@ -0,0 +1,169 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// What the node-engine finds when it compares the compositions it made with the ones it runs (issue 1126): the bus's user +// list left out, a resource that differs named with both digests, a value sealed again judged by what it opens to, every +// differing body shown with its secrets masked, and the paths found on the whole bodies. Moved from mesh-sdk's delivery +// package with the code (ADR 0634 §1: only the node-engine compares compositions). +func TestComparingCompositions(t *testing.T) { + // CompareComposed: the bus's user list is left out on both sides; anything else that differs is named. + file := func(id, content string) json.RawMessage { + return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`) + } + fp := "src2:" + strings.Repeat("a", 64) + comp := func(rs ...json.RawMessage) delivery.Composed { + return delivery.Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs} + } + for name, tc := range map[string]struct { + composed, running delivery.Composed + resource, why string + }{ + "user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""}, + "a resource": {comp(file("conf", "a")), comp(file("conf", "b")), "conf", "the resource differs"}, + "one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"}, + "order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"}, + } { + c := CompareComposed(map[string]delivery.Composed{"novox.nats." + fp: tc.composed}, []delivery.Composed{tc.running}) + if tc.why == "" { + if len(c.Differs) != 0 || len(c.Same) != 1 { + t.Errorf("%s: %+v", name, c) + } + } else if len(c.Differs) != 1 || c.Differs[0].Resource != tc.resource || c.Differs[0].Why != tc.why || + c.Differs[0].Composed == "" || c.Differs[0].Running == "" || c.Differs[0].Composed == c.Differs[0].Running { + t.Errorf("%s: differs %+v, want %s: %s with both digests", name, c.Differs, tc.resource, tc.why) + } + } + // Issue 1126, the shadow shows its work: every differing resource with both bodies, each value a secret could be + // in masked (never a secret in an answer), saying whether the two sides hold the same value. + sealedFile := func(id, sealed, mode string) json.RawMessage { + return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"` + mode + `","sealed":"` + sealed + `"}`) + } + // Issue 1126, C1: a sealed value is judged by the value it opens to at its own slot: b sealed again (box-2 and + // box-3 open to one value) is no difference; d holds at its slot a box of another value, though that value is + // the one b holds, and differs at its path `sealed`. + sameBox := func(a, b string) (same, known bool) { + return (a == "box-2" && b == "box-3") || a == b, true + } + shown := CompareComposedWith(map[string]delivery.Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), + file("c", "x"), sealedFile("d", "box-2", "0600"))}, + []delivery.Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"), sealedFile("d", "box-4", "0600"))}, + sameBox) + if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 { + t.Fatalf("every differing resource is not shown, or b sealed again is shown: %+v", shown.Differs) + } + if c := CompareComposedWith(map[string]delivery.Composed{"k": comp(sealedFile("b", "box-2", "0600"))}, + []delivery.Composed{comp(sealedFile("b", "box-3", "0600"))}, sameBox); len(c.Same) != 1 { + t.Errorf("a value sealed again at its own slot is not the same: %+v", c) + } + for _, row := range []struct { + id, has, hasNot string + paths []string + }{ + {"a", `"mode":"0600"`, "box-1", []string{"mode"}}, + {"c", `"content":"x"`, "", []string{"content"}}, + {"d", "", "box-2", []string{"sealed"}}, + } { + var body delivery.ResourceBodies + for _, b := range shown.Differs[0].Bodies { + if b.ID == row.id { + body = b + } + } + both := body.Composed + body.Running + if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) || + !slices.Equal(body.Paths, row.paths) { + t.Errorf("%s shown as %+v, want paths %v", row.id, body, row.paths) + } + } + // Issue 1126, C1, the seed-7 probe: a file naming secrets carries each sealed value in its `secrets`, and each entry + // is a slot of its own. The shape is intoFile's output, captured from catalogue for a file naming two secrets + // (2026-10-28), with the module a composition names each resource of; only the boxes are replaced. + withSecrets := func(db, key string) json.RawMessage { + return json.RawMessage(`{"content":"[database]\npassword = ${secret:postgres-database}\n[security]\nsecret_key = ${secret:secret-key}\n",` + + `"id":"grafana.ini","mode":"0600","module":"nats","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":"` + db + + `","secret-key":"` + key + `"},"type":"file"}`) + } + if c := CompareComposedWith(map[string]delivery.Composed{"k": comp(withSecrets("box-2", "box-1"))}, + []delivery.Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 { + t.Errorf("a secret sealed again at its own entry is not the same: %+v", c) + } + if kept := KeepSeals(comp(withSecrets("box-2", "box-1")), SealsIn(comp(withSecrets("box-3", "box-1"))), sameBox); !bytes.Equal(kept.Resources[0], withSecrets("box-3", "box-1")) { + t.Errorf("the box that runs is not kept byte for byte: %s", kept.Resources[0]) + } + if s := SealsIn(comp(withSecrets("box-2", "box-1"))); s[SealSlot("grafana.ini", SecretSlotField("postgres-database"))] != "box-2" || len(s) != 2 { + t.Errorf("the secrets' slots are %v", s) + } + // Another value at one entry differs there alone, each entry masked by what it opens to. + // box-2 and box-4 open to one value, so the box that runs is kept there; box-5 and box-6 are two values. + mixed := CompareComposedWith(map[string]delivery.Composed{"k": comp(withSecrets("box-2", "box-5"))}, + []delivery.Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) { + return (a == "box-2" && b == "box-4") || a == b, true + }) + if len(mixed.Differs) != 1 || len(mixed.Differs[0].Bodies) != 1 { + t.Fatalf("a secret of another value is not shown: %+v", mixed) + } + if body := mixed.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"secrets.secret-key"}) || + !strings.Contains(body.Composed, `"secret-key":""`) || strings.Contains(body.Composed+body.Running, "box-") || + !strings.Contains(body.Running, `"postgres-database":""`) { + t.Errorf("a file's secrets shown as %+v", body) + } + if c, _ := MaskedPair(withSecrets("box-2", "box-5"), withSecrets("box-4", "box-6"), func(a, b string) (bool, bool) { + return a == "box-2", true + }); !strings.Contains(c, `"postgres-database":""`) || !strings.Contains(c, `"secret-key":""`) { + t.Errorf("each secret is not masked by what it opens to: %s", c) + } + // Only a file's secrets are split by entry: a vault box (an object under `vault-sealed`) is one masked word and one + // path, judged whole by what it opens to (rev-seed-same-3, round 1). + box := func(ct string) json.RawMessage { + return json.RawMessage(`{"id":"core-box","type":"file","module":"nats","path":"/etc/core","mode":"0600","vault-sealed":{"v":1,"eph":"e-` + + ct + `","ct":"` + ct + `"}}`) + } + vault := CompareComposedWith(map[string]delivery.Composed{"k": comp(box("c1"))}, []delivery.Composed{comp(box("c2"))}, + func(a, b string) (bool, bool) { + return false, strings.Contains(a, `"ct":"c1"`) && strings.Contains(b, `"ct":"c2"`) + }) + if len(vault.Differs) != 1 || len(vault.Differs[0].Bodies) != 1 { + t.Fatalf("a box of another value is not shown: %+v", vault) + } + if body := vault.Differs[0].Bodies[0]; !slices.Equal(body.Paths, []string{"vault-sealed"}) || + !strings.Contains(body.Composed, `"vault-sealed":""`) || strings.Contains(body.Composed+body.Running, "c1") { + t.Errorf("a vault box shown as %+v", body) + } + // Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a + // key a secret could be under is named and never shown. + long := strings.Repeat("x", delivery.MaxBody) + conf := func(link, password string) json.RawMessage { + inner, _ := json.Marshal(map[string]any{"pad": long, "links": []string{"a", link}, "password": password}) + outer, _ := json.Marshal(map[string]any{"id": "conf", "type": "file", "module": "nats", "path": "/etc/conf", + "mode": "0600", "content": string(inner)}) + return outer + } + far := CompareComposedWith(map[string]delivery.Composed{"k": comp(conf("b", "hunter2"))}, []delivery.Composed{comp(conf("c", "hunter3"))}, nil) + if len(far.Differs) != 1 || len(far.Differs[0].Bodies) != 1 || + !slices.Equal(far.Differs[0].Bodies[0].Paths, []string{"content.links.1", "content.password"}) || + !strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") { + t.Errorf("a difference past the cut: %+v", far.Differs) + } + if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`), + json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") || + strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "") || + !strings.Contains(c, "") { + t.Errorf("masked as %s and %s", c, r) + } + if c := CompareComposed(nil, []delivery.Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" { + t.Errorf("the node's own composition run and not composed is removed: %+v", c) + } + if c := CompareComposed(map[string]delivery.Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" { + t.Errorf("a composition not run is added: %+v", c) + } +} diff --git a/internal/compositions/composed_compare.go b/internal/compositions/composed_compare.go new file mode 100644 index 0000000..fc244e0 --- /dev/null +++ b/internal/compositions/composed_compare.go @@ -0,0 +1,412 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "errors" + "git.novox.be/novox/mesh-sdk/go/delivery" + "io" + "maps" + "slices" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Comparison is what CompareComposed finds, module by module: the same, differing, added by what was composed (not run +// now) and removed by it (run now, not composed). Each list is sorted by module. +type Comparison struct { + Same []string + Differs []delivery.ModuleDiffers + Added []delivery.ModuleAdded + Removed []string +} + +// CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones +// it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its +// composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing +// of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and +// not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out +// on both sides: the bus node follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A +// difference names the first resource, by its id, whose content differs or that one side alone holds; when every +// resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file +// carried by digest). +func CompareComposed(composed map[string]delivery.Composed, running []delivery.Composed) Comparison { + return CompareComposedWith(composed, running, nil) +} + +// CompareComposedWith is CompareComposed showing, for each module that differs, every differing resource with both +// bodies masked (MaskedPair); sameValue, when given, says whether two sealed values open to the same value (the node +// opens them with its own key), so a value sealed again reads as such. +func CompareComposedWith(composed map[string]delivery.Composed, running []delivery.Composed, sameValue func(a, b string) (same, known bool)) Comparison { + name := func(c delivery.Composed) string { + if c.Module == "" { + return delivery.HaveNodeWord + } + return c.Module + } + ours := map[string]delivery.Composed{} + for _, c := range composed { + ours[name(c)] = c + } + theirs := map[string]delivery.Composed{} + for _, c := range running { + theirs[name(c)] = c + } + modules := map[string]bool{} + for m := range ours { + modules[m] = true + } + for m := range theirs { + modules[m] = true + } + var out Comparison + for _, m := range slices.Sorted(maps.Keys(modules)) { + a, composedHere := ours[m] + b, runs := theirs[m] + switch { + case !runs: + dig, _ := delivery.ComposedDigestOf(withoutBusUsers(a)) + out.Added = append(out.Added, delivery.ModuleAdded{Module: m, Composed: dig}) + case !composedHere: + out.Removed = append(out.Removed, m) + default: + if d, ok := compareOne(m, a, b, sameValue); ok { + out.Same = append(out.Same, m) + } else { + out.Differs = append(out.Differs, d) + } + } + } + return out +} + +// compareOne is one module's comparison: true when the digests agree, else what differs first. +func compareOne(module string, composed, running delivery.Composed, sameValue func(a, b string) (bool, bool)) (delivery.ModuleDiffers, bool) { + composed, running = withoutBusUsers(composed), withoutBusUsers(running) + // A sealed value is judged by the value it opens to, slot by slot (issue 1126, C1): a value sealed again at the same + // field of the same resource is the box that runs, never a difference; and a box is never matched across resources. + composed = KeepSeals(composed, SealsIn(running), sameValue) + dc, errC := delivery.ComposedDigestOf(composed) + dr, errR := delivery.ComposedDigestOf(running) + if errC != nil { + return delivery.ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false + } + if errR == nil && dc == dr { + return delivery.ModuleDiffers{}, true + } + differs := func(resource, why string) (delivery.ModuleDiffers, bool) { + return delivery.ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false + } + // Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole + // and the same file carried by digest read as one. + a, _, errA := composed.ByDigest() + b, _, errB := running.ByDigest() + if errA != nil || errB != nil { + return differs("", "the compositions differ and cannot be read resource by resource") + } + ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources) + ids := map[string]bool{} + for id := range ra { + ids[id] = true + } + for id := range rb { + ids[id] = true + } + var first delivery.ModuleDiffers + found := false + var bodies []delivery.ResourceBodies + for _, id := range slices.Sorted(maps.Keys(ids)) { + x, inA := ra[id] + y, inB := rb[id] + why := "" + switch { + case !inB: + why = "composed, and not in what runs" + case !inA: + why = "in what runs, and not composed" + case !bytes.Equal(x, y): + why = "the resource differs" + default: + continue + } + if !found { + first, _ = differs(id, why) + found = true + } + if len(bodies) < delivery.MaxBodies { + // The paths are found on the whole bodies, before either is cut (issue 1126, T1: three resources over + // MaxBody could not be told apart). + c, r := MaskedPair(x, y, sameValue) + bodies = append(bodies, delivery.ResourceBodies{ID: id, Why: why, Paths: DifferingPaths(x, y), + Composed: cut(c), Running: cut(r)}) + } + } + if found { + first.Bodies = bodies + return first, false + } + switch { + case !jsonEqual(a.Filled, b.Filled): + return differs("", "the files the provisioner fills differ") + case a.Data != b.Data: + return differs("", "whether it declares data differs") + case !jsonEqual(a.Own, b.Own): + return differs("", "the node's own parts differ") + case a.FP != b.FP: + return differs("", "the fingerprint differs") + } + if dA, err := delivery.ComposedDigestOf(a); err == nil { + if dB, err := delivery.ComposedDigestOf(b); err == nil && dA == dB { + return differs("", "the same content, a large file carried whole on one side and by digest on the other") + } + } + return differs("", "the same resources in another order") +} + +// withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `.bus-users`). +func withoutBusUsers(c delivery.Composed) delivery.Composed { + out := c + out.Resources = nil + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + } + if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) { + continue + } + out.Resources = append(out.Resources, raw) + } + return out +} + +// resourcesByID is each resource canonical, by its id (a resource without one by its place). +func resourcesByID(resources []json.RawMessage) map[string][]byte { + out := make(map[string][]byte, len(resources)) + for i, raw := range resources { + var head struct { + ID string `json:"id"` + } + _ = json.Unmarshal(raw, &head) + id := head.ID + if id == "" { + id = "#" + strconv.Itoa(i) + } + canon, err := canonicalJSON(raw) + if err != nil { + canon = raw + } + out[id] = canon + } + return out +} + +// cut is s cut at MaxBody bytes, saying so. +func cut(s string) string { + if len(s) <= delivery.MaxBody { + return s + } + return s[:delivery.MaxBody] + "… (cut at " + strconv.Itoa(delivery.MaxBody) + " bytes)" +} + +// secretWords are the words of a key whose value may hold a secret or a sealed one: masked in a shown body. +var secretWords = []string{"sealed", "secret", "password", "passwd", "token", "private", "key", "credential", "jwt", "salt", "hash"} + +func secretKey(k string) bool { + k = strings.ToLower(k) + for _, w := range secretWords { + if strings.Contains(k, w) { + return true + } + } + return false +} + +// MaskedPair is two bodies of one resource (either may be nil) as JSON text with every value under a key a secret could +// be in (secretWords, at any depth) masked: "", "" or "" when sameValue says so, "" otherwise, "" on one side +// alone. A body that is not JSON is shown as its length only. +func MaskedPair(composed, running json.RawMessage, sameValue func(a, b string) (same, known bool)) (string, string) { + var a, b any + okA := composed != nil && json.Unmarshal(composed, &a) == nil + okB := running != nil && json.Unmarshal(running, &b) == nil + if okA && okB { + a, b = maskBoth(a, b, sameValue) + } else if okA { + a, _ = maskBoth(a, nil, nil) + } else if okB { + _, b = maskBoth(nil, b, nil) + } + show := func(v any, raw json.RawMessage, ok bool) string { + if raw == nil { + return "" + } + if !ok { + return "" + } + var out bytes.Buffer + enc := json.NewEncoder(&out) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + return strings.TrimSuffix(out.String(), "\n") + } + return show(a, composed, okA), show(b, running, okB) +} + +func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) { + ma, isA := a.(map[string]any) + mb, isB := b.(map[string]any) + if isA || isB { + outA, outB := map[string]any{}, map[string]any{} + keys := map[string]bool{} + for k := range ma { + keys[k] = true + } + for k := range mb { + keys[k] = true + } + for k := range keys { + va, inA := ma[k] + vb, inB := mb[k] + if secretKey(k) { + x, y := maskAll(k, va, vb, inA && inB, sameValue) + if inA { + outA[k] = x + } + if inB { + outB[k] = y + } + continue + } + x, y := maskBoth(va, vb, sameValue) + if inA { + outA[k] = x + } + if inB { + outB[k] = y + } + } + var ra, rb any + if isA { + ra = outA + } else { + ra = a + } + if isB { + rb = outB + } else { + rb = b + } + return ra, rb + } + la, isLA := a.([]any) + lb, isLB := b.([]any) + if isLA || isLB { + outA := make([]any, len(la)) + outB := make([]any, len(lb)) + for i := range max(len(la), len(lb)) { + var x, y any + if i < len(la) { + x = la[i] + } + if i < len(lb) { + y = lb[i] + } + x, y = maskBoth(x, y, sameValue) + if i < len(la) { + outA[i] = x + } + if i < len(lb) { + outB[i] = y + } + } + var ra, rb any = a, b + if isLA { + ra = outA + } + if isLB { + rb = outB + } + return ra, rb + } + return a, b +} + +// maskAll is a value under a key a secret could be under, masked as one word (maskWord); a file's secrets +// (SecretsField) alone entry by entry, each its own sealed value: "another seal of the same value" or "another value" +// per secret, its name shown (issue 1126, C1). Any other object, a vault box included, is one word, judged whole. +func maskAll(key string, a, b any, both bool, sameValue func(a, b string) (bool, bool)) (any, any) { + ma, isA := a.(map[string]any) + mb, isB := b.(map[string]any) + if key != SecretsField || (!isA && !isB) { + w := maskWord(a, b, both, sameValue) + return w, w + } + outA, outB := map[string]any{}, map[string]any{} + for k, va := range ma { + vb, inB := mb[k] + outA[k] = maskWord(va, vb, isB && inB, sameValue) + } + for k, vb := range mb { + va, inA := ma[k] + outB[k] = maskWord(va, vb, isA && inA, sameValue) + } + // A map on one side and another value on the other: the other is one word. + var ra, rb any = outA, outB + if !isA { + ra = maskWord(a, b, both, sameValue) + } + if !isB { + rb = maskWord(a, b, both, sameValue) + } + return ra, rb +} + +func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string { + if !both { + return "" + } + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + if bytes.Equal(x, y) { + return "" + } + if sameValue != nil { + sa, okA := a.(string) + sb, okB := b.(string) + if !okA { + sa = string(x) + } + if !okB { + sb = string(y) + } + if same, known := sameValue(sa, sb); known { + if same { + return "" + } + return "" + } + } + return "" +} + +// canonicalJSON is raw read as one JSON value (numbers kept as written) and written again, so equal values compare equal. +func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + return nil, err + } + if _, err := d.Token(); err != io.EOF { + return nil, errors.New("more after the value") + } + return json.Marshal(v) +} + +func jsonEqual(a, b any) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} diff --git a/internal/compositions/composed_seals.go b/internal/compositions/composed_seals.go new file mode 100644 index 0000000..a3374da --- /dev/null +++ b/internal/compositions/composed_seals.go @@ -0,0 +1,195 @@ +package compositions + +import ( + "bytes" + "encoding/json" + "git.novox.be/novox/mesh-sdk/go/delivery" + "sort" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Seals is, by slot, each value one composition carries sealed to its node's key (issue 1126, C1): a file's `sealed` +// a vault box (catalogue.VaultSealedField) and each entry of a file's secrets (SecretsField), as their JSON text (a box is carried as its JSON, a sealed value as +// the string it is). A slot is one field of one resource of the composition's module (SealSlot): a box is reused only +// at the slot that holds it, never for another resource whose value opens to the same (a provider's grant and its +// consumer's secret on one node hold one value). +type Seals = map[string]string + +// SealedFields are the fields of a resource that carry a value sealed to the node's key. +var SealedFields = []string{"sealed", catalogue.VaultSealedField} + +// SealSlot is the slot of one sealed field of one resource, by the resource's id (its place, `#`, when it has none). +// The module is the composition's: a slot is never compared across compositions. +func SealSlot(resource, field string) string { return resource + "#" + field } + +// SecretsField is the field of a file that carries, by name, each sealed value its content asks for +// (${secret:}, catalogue's intoFile): each entry is a slot of its own (SecretSlotField), so a secret sealed again +// is judged by the value it opens to, entry by entry, like a file's `sealed` (issue 1126, C1; the seed-7 probe: about 45 +// files on ace and novox differed by their secrets' bytes alone). +const SecretsField = "secrets" + +// SecretSlotField is the field part of the slot of one entry of a file's secrets (SealSlot(id, SecretSlotField(name))). +func SecretSlotField(name string) string { return SecretsField + "." + name } + +// sealAt is one sealed value of a resource: its slot's field, its place (a top-level field, or an entry of the +// secrets), and its raw JSON. +type sealAt struct { + field, entry string + raw json.RawMessage +} + +// sealsAt is every sealed value of one resource's fields, in a fixed order: SealedFields, then the secrets by name. +func sealsAt(fields map[string]json.RawMessage) []sealAt { + var out []sealAt + for _, field := range SealedFields { + if f, has := fields[field]; has { + out = append(out, sealAt{field: field, raw: f}) + } + } + var secrets map[string]json.RawMessage + if f, has := fields[SecretsField]; has && json.Unmarshal(f, &secrets) == nil { + names := make([]string, 0, len(secrets)) + for name := range secrets { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + out = append(out, sealAt{field: SecretSlotField(name), entry: name, raw: secrets[name]}) + } + } + return out +} + +// SealsIn is every sealed value c carries, by slot; nil when it carries none. +func SealsIn(c delivery.Composed) Seals { + var out Seals + for i, raw := range c.Resources { + id, fields, ok := sealedFieldsOf(raw, i) + if !ok { + continue + } + for _, s := range sealsAt(fields) { + if text := sealedText(s.raw); text != "" { + if out == nil { + out = Seals{} + } + out[SealSlot(id, s.field)] = text + } + } + } + return out +} + +// KeepSeals is c with each sealed value replaced by the one kept at the same slot, where same says the two open to the +// same value (known and same): a value the vault sealed again is then the box that runs, so the composition is the +// one that runs (ADR 0547 §3, issue 1126). A slot kept holds nothing for, or whose value differs or cannot be opened, +// keeps c's. Every other byte of a resource is kept as it is; c itself is not changed. +func KeepSeals(c delivery.Composed, kept Seals, same func(a, b string) (same, known bool)) delivery.Composed { + if len(kept) == 0 || same == nil { + return c + } + out := c + out.Resources = nil + for i, raw := range c.Resources { + id, fields, ok := sealedFieldsOf(raw, i) + if ok { + for _, s := range sealsAt(fields) { + ours := sealedText(s.raw) + theirs, held := kept[SealSlot(id, s.field)] + if !held || ours == "" || theirs == ours { + continue + } + if opens, known := same(ours, theirs); !known || !opens { + continue + } + value := json.RawMessage(theirs) + if s.raw[0] == '"' { + value, _ = json.Marshal(theirs) + } + if s.entry == "" { + if swapped, ok := withField(raw, s.field, value); ok { + raw = swapped + } + continue + } + // An entry of the secrets: swapped inside the secrets as they stand in raw, then the secrets in raw. + var now map[string]json.RawMessage + if json.Unmarshal(raw, &now) != nil { + continue + } + if secrets, ok := withField(now[SecretsField], s.entry, value); ok { + if swapped, ok := withField(raw, SecretsField, secrets); ok { + raw = swapped + } + } + } + } + out.Resources = append(out.Resources, raw) + } + return out +} + +// sealedFieldsOf is a resource's id (`#` when it has none) and its top-level fields, false when it is no object. +func sealedFieldsOf(raw json.RawMessage, i int) (string, map[string]json.RawMessage, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return "", nil, false + } + var id string + if f, ok := fields["id"]; !ok || json.Unmarshal(f, &id) != nil || id == "" { + id = "#" + strconv.Itoa(i) + } + return id, fields, true +} + +// sealedText is a sealed field as its text: a string as it is, a box as its JSON. +func sealedText(f json.RawMessage) string { + var text string + if json.Unmarshal(f, &text) == nil { + return text + } + var compact bytes.Buffer + if json.Compact(&compact, f) != nil { + return string(f) + } + return compact.String() +} + +// withField is the object raw with its top-level field replaced by value, every other byte as it was (so a composition +// whose sealed values are those that run is the one that runs, byte for byte); false when raw is no object holding it. +func withField(raw json.RawMessage, field string, value json.RawMessage) (json.RawMessage, bool) { + d := json.NewDecoder(bytes.NewReader(raw)) + if t, err := d.Token(); err != nil || t != json.Delim('{') { + return nil, false + } + for d.More() { + t, err := d.Token() + if err != nil { + return nil, false + } + key, _ := t.(string) + before := d.InputOffset() + var skip json.RawMessage + if err := d.Decode(&skip); err != nil { + return nil, false + } + if key != field { + continue + } + after := d.InputOffset() + // The span holds the colon, any space and the value: kept up to the colon, the value replaced. + span := raw[before:after] + colon := bytes.IndexByte(span, ':') + if colon < 0 { + return nil, false + } + out := make([]byte, 0, len(raw)-len(span)+colon+1+len(value)) + out = append(out, raw[:int(before)+colon+1]...) + out = append(out, value...) + out = append(out, raw[after:]...) + return out, true + } + return nil, false +} diff --git a/internal/link/have.go b/internal/link/have.go index 23757db..213f0d6 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "sort" "strings" "sync" @@ -49,7 +50,7 @@ type haveState struct { nodeComposed string // nodeSealed is, on a node composing its own, each value its own composition carries sealed to its key (ADR 0547 §3, // issue 1126): kept in its key so a restart reuses them and applies nothing again. - nodeSealed delivery.Seals + nodeSealed compositions.Seals // nodeCandidates is the node's answer to each placement candidate proposal (novox/hq ADR 0559 §4), written into its key. nodeCandidates map[string]delivery.CandidateAnswer // nodeComposes and nodeComposesWhy are whose compositions the node applies and why (HaveNode.Composes), written into @@ -722,7 +723,7 @@ func (q *Session) NoteRemoved(ctx context.Context, module, build string) { // NoteNode takes the digest of the node's own composition it runs and the values it carries sealed to the node's key // (nil for a composition the node did not make itself), and writes the node's key where it differs. -func (q *Session) NoteNode(ctx context.Context, composed string, sealed delivery.Seals) { +func (q *Session) NoteNode(ctx context.Context, composed string, sealed compositions.Seals) { q.init() s := &q.haves s.mu.Lock() diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 47a3061..d2b9466 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "slices" "strings" "sync" @@ -602,7 +603,7 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseRemoved { t.Fatalf("removed: %+v", got) } - q.NoteNode(context.Background(), dig, delivery.Seals{"core-box#sealed": "core"}) + q.NoteNode(context.Background(), dig, compositions.Seals{"core-box#sealed": "core"}) if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Composed != dig || len(n.SealedAt) != 1 || n.SealedAt["core-box#sealed"] != "core" { t.Fatalf("the node key, with its own composition's sealed values by slot: %+v %v", n, err) } diff --git a/internal/link/messages.go b/internal/link/messages.go index 59cebe1..96d0475 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -1,6 +1,7 @@ package link import ( + "github.com/novox/mesh-host/internal/compositions" "time" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -644,7 +645,7 @@ type ModuleFact struct { // Kept is what the node keeps of the module as it runs, for a manifest that does not say it yet (delivery.Have.Kept). Kept []string // Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt). - Sealed delivery.Seals + Sealed compositions.Seals // PullWait is, on a failed fact, the pull from a store its apply waits on (delivery.Have.PullWait, issue 1966 ENG-9). PullWait *delivery.PullWait } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 302f142..e9e9c0f 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -19,6 +19,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "maps" "reflect" "slices" @@ -66,7 +67,7 @@ type Env struct { Refused func(why string, held map[string]string) // Running is what the node runs now (agent.Everything), and Unseal opens a value sealed to this machine: a secret // the vault seals again for each ask is kept as the box the node already runs at the same slot (the same field of - // the same resource of the same module, delivery.KeepSeals) when that box opens to the same value, so neither the + // the same resource of the same module, compositions.KeepSeals) when that box opens to the same value, so neither the // switch to composing its own nor a restart changes a composition (ADR 0547 §3); a box held at another slot is // never taken, though it opens to the same value (issue 1126, C1: a provider's grant and its consumer's secret). // Either nil keeps the vault's box. @@ -77,7 +78,7 @@ type Env struct { // (delivery.Have.SealedAt); legacy is the list a have written before slots holds (delivery.Have.Sealed), reused // only within its own module. // An error is a have the bus did not answer for, never one absent: the pass is then tried again (BusFailed). - Held func(module string) (slots delivery.Seals, legacy []string, err error) + Held func(module string) (slots compositions.Seals, legacy []string, err error) // Kept is told, after each pass that composed, what the node keeps of each module as it runs because its manifest does // not yet say it (ADR 0601 §4), by module, so each module's have carries it (agent.KeptNow). Nil tells nobody. Kept func(kept map[string][]string) @@ -1093,7 +1094,7 @@ func (c *Composer) heldNow() []string { } // keepRunning is comp with each sealed value the node already runs at the same slot kept, where it opens to the same -// value (delivery.KeepSeals, issue 1126, C1): of what runs (running, the agent's), else of what its module's have says +// value (compositions.KeepSeals, issue 1126, C1): of what runs (running, the agent's), else of what its module's have says // it runs (Held). A have written before slots names no slot: its boxes are reused within their own module alone, at a // slot no box is kept for, and only one opening to the slot's value. func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Composed) delivery.Composed { @@ -1104,7 +1105,7 @@ func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Compos if module == "" { module = agent.NodeModule } - kept := delivery.Seals{} + kept := compositions.Seals{} var legacy []string if c.env.Held != nil { slots, old, err := c.env.Held(module) @@ -1116,12 +1117,12 @@ func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Compos } for _, r := range running { if r.Module == comp.Module { - for slot, box := range delivery.SealsIn(r) { + for slot, box := range compositions.SealsIn(r) { kept[slot] = box } } } - for slot, box := range delivery.SealsIn(comp) { + for slot, box := range compositions.SealsIn(comp) { if _, ok := kept[slot]; ok { continue } @@ -1132,7 +1133,7 @@ func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Compos } } } - return delivery.KeepSeals(comp, kept, c.sameValue) + return compositions.KeepSeals(comp, kept, c.sameValue) } // waitLog is what a composer waited on: how many waits ended, the longest and what it was, and the last. diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index a33a7e5..eef2e64 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/novox/mesh-host/internal/compositions" "os" "reflect" "slices" @@ -826,7 +827,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - sealed := delivery.SealsIn(c) + sealed := compositions.SealsIn(c) if len(sealed) != 1 { t.Fatalf("keeper's composition carries %v", sealed) } @@ -839,20 +840,20 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { _, beforeBox := boxOf(before) for _, row := range []struct { name, in, held string - slots delivery.Seals + slots compositions.Seals kept bool fails bool // the have's read the bus did not answer: no composition, made again later (2026-10-28) }{ - {"the have holds the rotated value at its slot", "keeper", rotatedBox, delivery.Seals{slot: rotatedBox}, true, false}, - {"the have holds the value before the rotation", "keeper", beforeBox, delivery.Seals{slot: beforeBox}, false, false}, + {"the have holds the rotated value at its slot", "keeper", rotatedBox, compositions.Seals{slot: rotatedBox}, true, false}, + {"the have holds the value before the rotation", "keeper", beforeBox, compositions.Seals{slot: beforeBox}, false, false}, {"a have written before slots holds the rotated value", "keeper", rotatedBox, nil, true, false}, - {"another module holds the same value", "lemurs", rotatedBox, delivery.Seals{"lemurs.grant-x#sealed": rotatedBox}, false, false}, - {"another resource of keeper holds the same value", "keeper", rotatedBox, delivery.Seals{"keeper.other#sealed": rotatedBox}, false, false}, + {"another module holds the same value", "lemurs", rotatedBox, compositions.Seals{"lemurs.grant-x#sealed": rotatedBox}, false, false}, + {"another resource of keeper holds the same value", "keeper", rotatedBox, compositions.Seals{"keeper.other#sealed": rotatedBox}, false, false}, {"the bus does not answer for the have", "keeper", rotatedBox, nil, false, true}, } { restarted := env restarted.Running = func() []delivery.Composed { return nil } - restarted.Held = func(module string) (delivery.Seals, []string, error) { + restarted.Held = func(module string) (compositions.Seals, []string, error) { if module != row.in { return nil, nil, nil } @@ -929,13 +930,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } return nil, fmt.Errorf("not sealed to n1: %s", box) } - coreEnv.Held = func(module string) (delivery.Seals, []string, error) { + coreEnv.Held = func(module string) (compositions.Seals, []string, error) { if module == agent.NodeModule { - return delivery.Seals{"core-box#sealed": rotatedBox}, nil, nil + return compositions.Seals{"core-box#sealed": rotatedBox}, nil, nil } return nil, nil, nil } - if got := delivery.SealsIn(New(coreEnv).keepRunning(ownComp, nil))["core-box#sealed"]; got != rotatedBox { + if got := compositions.SealsIn(New(coreEnv).keepRunning(ownComp, nil))["core-box#sealed"]; got != rotatedBox { t.Errorf("the node's own core box after a restart is %s, want the one its key holds %s", got, rotatedBox) } // Issue 1126, C1, the seed-7 probe: each entry of a file's secrets (catalogue's intoFile, the shape captured from it @@ -953,20 +954,20 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } for _, row := range []struct { name string - held delivery.Seals + held compositions.Seals want string }{ - {"the box that runs at its entry", delivery.Seals{"grafana.ini#secrets.postgres-database": rotatedBox}, rotatedBox}, - {"a box at another entry", delivery.Seals{"grafana.ini#secrets.secret-key": rotatedBox}, "box-again"}, + {"the box that runs at its entry", compositions.Seals{"grafana.ini#secrets.postgres-database": rotatedBox}, rotatedBox}, + {"a box at another entry", compositions.Seals{"grafana.ini#secrets.secret-key": rotatedBox}, "box-again"}, } { secretsEnv := coreEnv - secretsEnv.Held = func(module string) (delivery.Seals, []string, error) { + secretsEnv.Held = func(module string) (compositions.Seals, []string, error) { if module == "grafana" { return row.held, nil, nil } return nil, nil, nil } - got := delivery.SealsIn(New(secretsEnv).keepRunning(ini("box-again", "box-other"), nil)) + got := compositions.SealsIn(New(secretsEnv).keepRunning(ini("box-again", "box-other"), nil)) if got["grafana.ini#secrets.postgres-database"] != row.want || got["grafana.ini#secrets.secret-key"] != "box-other" { t.Errorf("%s: the secrets kept are %v", row.name, got) } @@ -1009,7 +1010,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // What runs at the credential's slot is kept, after the composition (keepRunning, issue 1126, C1). webComp := delivery.Composed{Node: "n1", Module: "web", Resources: []json.RawMessage{json.RawMessage(`{"id":"web.secret-pg","sealed":"{\"c\":1}"}`)}} - if got := delivery.SealsIn(pc.keepRunning(webComp, pairEnv.Running()))["web.secret-pg#sealed"]; got != `{"run":1}` { + if got := compositions.SealsIn(pc.keepRunning(webComp, pairEnv.Running()))["web.secret-pg#sealed"]; got != `{"run":1}` { t.Errorf("web's credential composed as %s, want the box that runs", got) } // A node that cannot compose tells the agent once, and once again when it composes (the agent's haves say it). -- 2.54.0 From cdd428a7d86a260dbbcc309a947789b96c7b940e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:57:50 +0100 Subject: [PATCH 36/73] =?UTF-8?q?Node-engine:=20the=20whole=20seat-list=20?= =?UTF-8?q?case=20proves=20a=20fresh=20node=20asks=20the=20seat=20over=20i?= =?UTF-8?q?ts=20own=20grant=20while=20its=20own=20composition=20and=20web?= =?UTF-8?q?=20are=20held,=20no=20part=20needs=20letting=20through=20(rulin?= =?UTF-8?q?g=2015:1x=20b,=20ADR=200636=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/nodeagent_integration_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index ba08a53..a175086 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -60,6 +60,8 @@ type itBed struct { mu sync.Mutex // seatSilent makes the stand-in of the seat mesh-catalog refuse the seat list (it is served from the start otherwise). seatSilent atomic.Bool + // seatAsked counts the node's asks of the stand-in seat, answered or refused: the node reached the bus and asked. + seatAsked atomic.Int32 findings nats.KeyValue } @@ -904,6 +906,7 @@ func (b *itBed) standInSeat() { Seat string `json:"seat"` } _ = json.Unmarshal(m.Data, &q) + b.seatAsked.Add(1) var result any switch { case b.seatSilent.Load(): @@ -979,6 +982,17 @@ func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) { defer clock.mu.Unlock() return len(clock.fire) > 0 }) + // A fresh node, nothing applied, reaches the bus and asks the seat while its own composition is held: the read needs + // the bus the node composes from and the grant it has from its start, nothing of what the hold keeps back. + if b.seatAsked.Load() == 0 { + t.Fatal("the fresh node did not ask the seat for its list while its own composition was held") + } + within(t, 5*time.Second, "the fresh node did not say it holds web and its own composition for the unread list", func() bool { + b.mu.Lock() + defer b.mu.Unlock() + said := strings.Join(b.said, "\n") + return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && strings.Contains(said, selfcompose.WaitsForTheSeatList) + }) if b.file("web") != "" { t.Fatalf("the node applied web before it read the seat list: %q", b.file("web")) } -- 2.54.0 From 0871fa7cc4073b34d1483da7fb8461aaaa0f4fb4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:59:39 +0100 Subject: [PATCH 37/73] =?UTF-8?q?Node-engine:=20the=20value=20version=20co?= =?UTF-8?q?mes=20from=20mesh-sdk=20go/keys,=20not=20go/delivery=20(H-2,=20?= =?UTF-8?q?ADR=200634=20=C2=A71,=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/compose/frombus/export.go | 3 ++- internal/link/handacts.go | 4 ++-- internal/link/have.go | 9 +++++---- internal/link/have_test.go | 19 ++++++++++--------- internal/link/record.go | 3 ++- internal/link/session.go | 3 ++- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/internal/compose/frombus/export.go b/internal/compose/frombus/export.go index 8726c0f..432e7fb 100644 --- a/internal/compose/frombus/export.go +++ b/internal/compose/frombus/export.go @@ -2,6 +2,7 @@ package frombus import ( "fmt" + sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "sort" "strconv" @@ -27,7 +28,7 @@ func Export(in compose.Input, node string) (delivery.Placement, error) { } // At the version a placement key is read at, with a list of modules even when none is placed: the export is the // seed's value, and the reader refuses a value of version 0 or without its modules. - p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ + p := delivery.Placement{V: sdkkeys.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} diff --git a/internal/link/handacts.go b/internal/link/handacts.go index 6592603..cfffffd 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "sort" "strconv" "strings" @@ -17,7 +18,6 @@ import ( occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The node seat node-engine's acts (novox/hq ADR 0563). @@ -273,7 +273,7 @@ func (h *HandActs) Call(c EngineCall) (any, error) { return nil, errors.New("this engine states no manifest reads yet: its node agent has not started") } reads, at := h.ManifestReads() - return nodeengine.ManifestReadsAnswer{V: delivery.Version, Node: h.Node, ManifestReads: reads, + return nodeengine.ManifestReadsAnswer{V: sdkkeys.Version, Node: h.Node, ManifestReads: reads, StatedAt: at.UTC().Format(time.RFC3339Nano)}, nil case "acts": if id := arg("id"); id != "" { diff --git a/internal/link/have.go b/internal/link/have.go index 213f0d6..af8ac34 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "github.com/novox/mesh-host/internal/compositions" "sort" "strings" @@ -205,7 +206,7 @@ func resourceWords(r ResourceHealth) string { // nodeFacts is the node's own facts of a report, each as the JSON the report carries it in. func nodeFacts(r Report) delivery.HaveNode { - n := delivery.HaveNode{V: delivery.Version} + n := delivery.HaveNode{V: sdkkeys.Version} carry := func(into *json.RawMessage, empty bool, v any) { if empty { return @@ -375,7 +376,7 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { sort.Strings(modules) for _, module := range modules { f := s.modules[module] - h := delivery.Have{V: delivery.Version, FP: f.FP, Composed: f.Composed, Build: f.Build, + h := delivery.Have{V: sdkkeys.Version, FP: f.FP, Composed: f.Composed, Build: f.Build, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown, Detail: f.Detail} h.Kept = f.Kept if delivery.Phase(f.Phase) == delivery.PhaseWaitingYes { @@ -507,7 +508,7 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b delete(s.removed, module) continue } - h := delivery.Have{V: delivery.Version, Build: s.removed[module], Phase: delivery.PhaseRemoved, + h := delivery.Have{V: sdkkeys.Version, Build: s.removed[module], Phase: delivery.PhaseRemoved, Health: delivery.HealthUnknown, Detail: "the applied declaration no longer holds the module"} raw, err := delivery.EncodeHave(h) if err != nil { @@ -730,7 +731,7 @@ func (q *Session) NoteNode(ctx context.Context, composed string, sealed composit s.nodeComposed = composed s.nodeSealed = sealed if s.node == nil { - n := delivery.HaveNode{V: delivery.Version} + n := delivery.HaveNode{V: sdkkeys.Version} s.node = &n } s.mu.Unlock() diff --git a/internal/link/have_test.go b/internal/link/have_test.go index d2b9466..11669f6 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "github.com/novox/mesh-host/internal/compositions" "slices" "strings" @@ -178,8 +179,8 @@ func TestAnAppliedModuleIsWrittenRunningAndAFailedOneFailed(t *testing.T) { // 1132/AC5: a have equal to the bucket's value is not written; a different one is written over the last revision. func TestAHaveIsWrittenOnlyWhenItDiffers(t *testing.T) { l := newHaveLink() - l.put(t, "n1.a", delivery.Have{V: delivery.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - l.put(t, "n1.b", delivery.Have{V: delivery.Version, Build: "b-0", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.a", delivery.Have{V: sdkkeys.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.b", delivery.Have{V: sdkkeys.Version, Build: "b-0", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) q, _ := aHaveQueue(l) q.SayFacts(context.Background(), appliedModules()) if l.kv["n1.a"].rev != 1 { @@ -258,7 +259,7 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { t.Fatal(err) } enc := func(v any) json.RawMessage { b, _ := json.Marshal(v); return b } - want := delivery.HaveNode{V: delivery.Version, Windows: enc(rep.Windows), Busy: enc(rep.Busy), Strays: enc(rep.Strays), + want := delivery.HaveNode{V: sdkkeys.Version, Windows: enc(rep.Windows), Busy: enc(rep.Busy), Strays: enc(rep.Strays), Filters: enc(rep.Filters), Profile: enc(rep.Profile)} if !delivery.SameHaveNode(got, want) { t.Errorf("the node key holds %+v, want %+v", got, want) @@ -280,7 +281,7 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { if got, err = delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || got.Record == nil { t.Fatalf("the node key holds no record: %+v, %v", got, err) } - wantRecord := delivery.NodeRecord{V: delivery.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", + wantRecord := delivery.NodeRecord{V: sdkkeys.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "bus-state-calls", "ledger-calls", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract, ManifestReads: catalogue.ManifestReads()} // what it reads of a manifest: its contract's (ADR 0614 §4) @@ -460,7 +461,7 @@ func TestARemovedModuleAddedBackIsWrittenWithItsNewPhase(t *testing.T) { // bucket is not created again. func TestAModuleNeverWrittenIsNotWrittenRemoved(t *testing.T) { l := newHaveLink() - l.put(t, "n1.c", delivery.Have{V: delivery.Version, Build: "b-9", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.c", delivery.Have{V: sdkkeys.Version, Build: "b-9", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) q, _ := aHaveQueue(l) q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), onlyA()) @@ -502,8 +503,8 @@ func TestARefusedRemovalIsWrittenByTheNextPass(t *testing.T) { // before: one of them the new declaration lacks is written removed, one that is not in the bucket is not created. func TestARestartedNodeEngineWritesTheRemovalFromTheReportsBefore(t *testing.T) { l := newHaveLink() - l.put(t, "n1.a", delivery.Have{V: delivery.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - l.put(t, "n1.b", delivery.Have{V: delivery.Version, Build: "b-2", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.a", delivery.Have{V: sdkkeys.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.b", delivery.Have{V: sdkkeys.Version, Build: "b-2", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) q, _ := aHaveQueue(l) // fresh: nothing written by this process r := onlyA() r.Before = []string{"a", "b", "c"} @@ -652,7 +653,7 @@ func TestAModuleSaidAgainOverAnOlderEnginesUnknownIsWrittenWithThisEnginesHealth l := newHaveLink() fp := "src2:" + strings.Repeat("a", 64) dig := "sha256:" + strings.Repeat("b", 64) - l.put(t, "n1.xorg", delivery.Have{V: delivery.Version, FP: fp, Composed: dig, Phase: delivery.PhaseRunning, + l.put(t, "n1.xorg", delivery.Have{V: sdkkeys.Version, FP: fp, Composed: dig, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown}) q, _ := aHaveQueue(l) if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) { @@ -748,7 +749,7 @@ func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) { // key that does not exist is not made up and is said once; a writer that drops the list again is written over. func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) { l := newHaveLink() - older := delivery.Have{V: delivery.Version, Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + older := delivery.Have{V: sdkkeys.Version, Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK} l.put(t, "n1.nats", older) q, said := aHaveQueue(l) dig := "sha256:" + strings.Repeat("c", 64) diff --git a/internal/link/record.go b/internal/link/record.go index fc19e2a..cd6a30d 100644 --- a/internal/link/record.go +++ b/internal/link/record.go @@ -2,6 +2,7 @@ package link import ( "encoding/json" + sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "slices" "sort" "sync" @@ -60,7 +61,7 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { if err != nil || json.Unmarshal(raw, &p) != nil { return nil } - rec := &delivery.NodeRecord{V: delivery.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, + rec := &delivery.NodeRecord{V: sdkkeys.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts, ManifestReads: manifestReads()} for _, c := range p.Capabilities { diff --git a/internal/link/session.go b/internal/link/session.go index ed8bb75..9af5818 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "strings" "sync" "time" @@ -413,7 +414,7 @@ func (q *Session) SayReads() { } q.reads.linked = true q.reads.Unlock() - q.SayEngine(nodeengine.EventManifestReadsChanged, nodeengine.ManifestReadsChangedEvent{V: delivery.Version, + q.SayEngine(nodeengine.EventManifestReadsChanged, nodeengine.ManifestReadsChangedEvent{V: sdkkeys.Version, Node: q.Membership.Node, ManifestReads: manifestReads(), At: time.Now().UTC().Format(time.RFC3339Nano), Why: why}) } -- 2.54.0 From 5906e18028442adc25cc9e496ffbe71520b718a6 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:10:45 +0100 Subject: [PATCH 38/73] =?UTF-8?q?Node-engine:=20a=20composition,=20its=20c?= =?UTF-8?q?odec,=20digest=20and=20keys=20come=20from=20the=20node-engine?= =?UTF-8?q?=20client;=20the=20candidate=20answer=20and=20its=20limits=20fr?= =?UTF-8?q?om=20the=20seat's=20contract;=20the=20declared=20packages=20are?= =?UTF-8?q?=20the=20engine's=20own=20(H-2,=20H-3,=20ADR=200634=20=C2=A71;?= =?UTF-8?q?=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's Composed keeps the catalogue's Plan and the declaration types and goes through nodeengineclient's codec; the fingerprint key part is meshcatalogclient's. The candidate answer is nodeengine.CandidateAnswer; the have still holds it through an interim JSON bridge (link.interimAnswers) until the Have group moves. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-compose/main_test.go | 4 +- cmd/mesh-host/authority.go | 4 +- cmd/mesh-host/authority_test.go | 5 +- cmd/mesh-host/empty_write_test.go | 7 +- cmd/mesh-host/file_references_test.go | 4 +- cmd/mesh-host/inactive.go | 6 +- cmd/mesh-host/inactive_test.go | 4 +- cmd/mesh-host/nodeagent.go | 41 +-- cmd/mesh-host/nodeagent_integration_test.go | 27 +- cmd/mesh-host/recorded_test.go | 7 +- cmd/mesh-host/runs_as_written.go | 6 +- cmd/mesh-host/seatlist.go | 2 +- cmd/mesh-host/userlist.go | 3 +- cmd/mesh-host/whatruns_test.go | 25 +- go.mod | 3 + internal/agent/agent.go | 60 ++--- internal/agent/agent_test.go | 82 +++--- internal/agent/composition.go | 13 +- internal/compose/composed.go | 29 +- internal/compose/composed_test.go | 29 +- internal/compose/frombus/answer.go | 43 +-- internal/compose/frombus/answer_rows_test.go | 56 ++-- internal/compose/frombus/frombus.go | 17 +- internal/compose/frombus/frombus_test.go | 59 ++-- .../frombus/frombustest/frombustest_test.go | 9 +- internal/compose/frombus/held_rows_test.go | 11 +- internal/compose/frombus/keep.go | 4 +- internal/compose/input.go | 6 +- internal/composer/composer_test.go | 3 +- internal/compositions/answer_paths.go | 7 +- internal/compositions/candidate.go | 53 ++++ internal/compositions/compare_test.go | 39 ++- internal/compositions/composed.go | 251 ++++++++++++++++++ internal/compositions/composed_compare.go | 54 ++-- internal/compositions/composed_seals.go | 5 +- internal/compositions/packages.go | 84 ++++++ internal/judge/aliases.go | 14 +- internal/judge/answer_test.go | 5 +- internal/link/composedstate.go | 6 +- internal/link/enginepackages.go | 17 +- internal/link/have.go | 25 +- internal/link/have_test.go | 3 +- internal/selfcompose/candidate.go | 13 +- internal/selfcompose/candidate_test.go | 2 +- internal/selfcompose/composedstate.go | 31 +-- internal/selfcompose/composedstate_test.go | 9 +- internal/selfcompose/data.go | 3 +- internal/selfcompose/data_test.go | 17 +- internal/selfcompose/packages.go | 17 +- internal/selfcompose/selfcompose.go | 35 +-- internal/selfcompose/selfcompose_test.go | 125 ++++----- internal/selfcompose/shadow.go | 43 +-- internal/userlist/userlist.go | 7 +- internal/userlist/userlist_test.go | 13 +- 54 files changed, 949 insertions(+), 498 deletions(-) create mode 100644 internal/compositions/candidate.go create mode 100644 internal/compositions/composed.go create mode 100644 internal/compositions/packages.go diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index 0b7de35..f70cf66 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -280,7 +280,7 @@ func ownPackages(t *testing.T) map[string][]string { } // 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard -// library, mesh-sdk, the private network's overlay package (mesh-wireguard's own pure package: names, graph and +// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the private network's overlay package (mesh-wireguard's own pure package: names, graph and // generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or // another process. func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { @@ -292,7 +292,7 @@ func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { for path, imports := range own { for _, imp := range imports { std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") - if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { + if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp) } for _, f := range forbidden { diff --git a/cmd/mesh-host/authority.go b/cmd/mesh-host/authority.go index 25f0903..dd9e3c6 100644 --- a/cmd/mesh-host/authority.go +++ b/cmd/mesh-host/authority.go @@ -2,12 +2,12 @@ package main import ( "encoding/json" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // authorityPath is where this engine keeps the mesh's own certificate as it last applied it (public): beside the @@ -17,7 +17,7 @@ func authorityPath(state string) string { return filepath.Join(filepath.Dir(stat // keepAuthority keeps the mesh's own certificate a composition carries (the resource a module's certificate.authority // asks for, catalogue.AuthorityID), so the record says the authority this machine holds. A composition carrying none // changes nothing. -func keepAuthority(state string, c delivery.Composed) error { +func keepAuthority(state string, c engcomp.Composed) error { for _, raw := range c.Resources { var r struct { ID string `json:"id"` diff --git a/cmd/mesh-host/authority_test.go b/cmd/mesh-host/authority_test.go index 7ff84f8..09f7333 100644 --- a/cmd/mesh-host/authority_test.go +++ b/cmd/mesh-host/authority_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" "strings" @@ -29,13 +30,13 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { raw, _ := json.Marshal(map[string]any{"id": id, "type": "file", "path": "/etc/x", "content": content}) return raw } - if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil { + if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil { t.Fatal(err) } if _, err := os.Stat(authorityPath(state)); !os.IsNotExist(err) { t.Fatalf("a composition with no authority kept one: %v", err) } - if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil { + if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil { t.Fatal(err) } if got := recordKeys(state, nil)(); got.Authority != "CA-1" { diff --git a/cmd/mesh-host/empty_write_test.go b/cmd/mesh-host/empty_write_test.go index 80a7f76..cac07d5 100644 --- a/cmd/mesh-host/empty_write_test.go +++ b/cmd/mesh-host/empty_write_test.go @@ -2,10 +2,9 @@ package main import ( "encoding/json" + engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // A composition that names a file the provisioner fills without saying it is filled would have this node-engine write @@ -13,7 +12,7 @@ import ( // when ace, g14 and shanks wrote claude-code's, node-env's and power's files empty). func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) { unit := json.RawMessage(`{"id":"power.after-boot-unit","type":"file","path":"/etc/systemd/system/mesh-power-after-boot.service","mode":"0644","owner-at-runtime":"owned"}`) - c := delivery.Composed{Node: "ace", Resources: []json.RawMessage{unit}} + c := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{unit}} d, err := declarationOf(c) if err != nil { t.Fatal(err) @@ -25,7 +24,7 @@ func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) { if d, _ = declarationOf(c); refuseEmptyWrites(d) != nil { t.Fatalf("a filled file was refused: %v", refuseEmptyWrites(d)) } - plain := delivery.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage( + plain := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage( `{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":"x"}`)}} if d, _ = declarationOf(plain); refuseEmptyWrites(d) != nil { t.Fatalf("a file with its content was refused: %v", refuseEmptyWrites(d)) diff --git a/cmd/mesh-host/file_references_test.go b/cmd/mesh-host/file_references_test.go index 0be46f6..891dd4f 100644 --- a/cmd/mesh-host/file_references_test.go +++ b/cmd/mesh-host/file_references_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" "strconv" @@ -11,7 +12,6 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/link" ) @@ -140,7 +140,7 @@ func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) { t.Fatal(err) } } - runs := runsAsWritten([]delivery.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state) + runs := runsAsWritten([]engcomp.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state) var got struct { Digest string `json:"digest"` } diff --git a/cmd/mesh-host/inactive.go b/cmd/mesh-host/inactive.go index 2d19e98..3113026 100644 --- a/cmd/mesh-host/inactive.go +++ b/cmd/mesh-host/inactive.go @@ -2,9 +2,9 @@ package main import ( "context" + engcomp "github.com/novox/mesh-host/internal/compositions" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/liveness" ) @@ -26,8 +26,8 @@ func heldBy(windows *apply.Windows) func(time.Time) map[string]bool { // long-running resource the composition declares running not active, read from the service manager and the container // runtime and from no record. A resource held by an open maintenance window, or waiting for a library, is not counted; // one that cannot be read is not counted either (unknown is not inactive), and neither is one the manager is restarting. -func inactiveIn(rt liveness.Runtime, held func(time.Time) map[string]bool) func(context.Context, delivery.Composed) bool { - return func(ctx context.Context, c delivery.Composed) bool { +func inactiveIn(rt liveness.Runtime, held func(time.Time) map[string]bool) func(context.Context, engcomp.Composed) bool { + return func(ctx context.Context, c engcomp.Composed) bool { d, err := declarationOf(c) if err != nil { return false diff --git a/cmd/mesh-host/inactive_test.go b/cmd/mesh-host/inactive_test.go index 24fb726..802fdfc 100644 --- a/cmd/mesh-host/inactive_test.go +++ b/cmd/mesh-host/inactive_test.go @@ -4,9 +4,9 @@ import ( "context" "encoding/json" "errors" + engcomp "github.com/novox/mesh-host/internal/compositions" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/liveness" ) @@ -77,7 +77,7 @@ func testTheMachineShowsAResourceNotActive(t *testing.T) { {"unread", fakeRuntime{err: errors.New("the service manager could not be read")}, nil, false}, } { t.Run(c.name, func(t *testing.T) { - d, err := declarationOf(delivery.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}}) + d, err := declarationOf(engcomp.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}}) if err != nil { t.Fatal(err) } diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 30d4c74..395d6a9 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" @@ -256,16 +257,16 @@ func awaitBackup(ctx context.Context, events <-chan []byte, node, module string, // the controller put modules it knew no fingerprint of the new form for into the node's own composition while their // wants named their own, and the declaration joined of both declared each such file twice, which the filling refuses // ("2 resources declare X") on every node. The node's own copy is left out, and which were left out is said once. -func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) { +func declarationOf(cs ...compositions.Composed) (*declaration.Declaration, error) { d := &declaration.Declaration{Version: declaration.Version} - var modules []delivery.Composed + var modules []compositions.Composed for _, c := range cs { if c.Module != "" { modules = append(modules, c) } } if len(modules) > 0 { - joined := make([]delivery.Composed, 0, len(cs)) + joined := make([]compositions.Composed, 0, len(cs)) for _, c := range cs { if c.Module == "" { var dropped []string @@ -300,7 +301,7 @@ func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) { // the same kind and id. A resource that shares only its target (a unit or container of the same name, another id) is // not the module's, and stays: dropped, its record would be an orphan the apply removes, stopping the module's unit. It // answers what it left out, as " ()". -func ownWithout(own delivery.Composed, modules []delivery.Composed) (delivery.Composed, []string) { +func ownWithout(own compositions.Composed, modules []compositions.Composed) (compositions.Composed, []string) { byID := map[string]string{} for _, c := range modules { if c.Module == "" { @@ -356,14 +357,14 @@ func sayDroppedOnce(dropped []string) { "declares, and they are left out of it: %s (issue 1945)\n", len(dropped), key) } -func scopeName(c delivery.Composed) string { +func scopeName(c compositions.Composed) string { if c.Module == "" { return agent.NodeModule } return c.Module } -func resourcesOf(cs []delivery.Composed) []declaration.Resource { +func resourcesOf(cs []compositions.Composed) []declaration.Resource { var out []declaration.Resource for _, c := range cs { for _, raw := range c.Resources { @@ -445,7 +446,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus } began := time.Now() node := session.Membership.Node - composedBucket, err := delivery.ComposedBucketFor(node) + composedBucket, err := compositions.BucketFor(node) if err != nil { cancel() say("the node agent cannot name this node's composition bucket: " + err.Error()) @@ -469,7 +470,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // composes its own (ADR 0555 §3): without it a module that asks for a certificate would be stood in for. if byModule, _ := kept(); byModule != nil { for module, resources := range byModule { - if err := keepAuthority(opts.state, delivery.Composed{Module: module, Resources: resources}); err != nil { + if err := keepAuthority(opts.state, compositions.Composed{Module: module, Resources: resources}); err != nil { say("the mesh's certificate the kept declaration carries could not be kept for this machine's record: " + err.Error()) } } @@ -478,7 +479,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // bus module this node runs declares, as its agent holds what runs (its own composition, or the declaration kept // before the switch): never read from the node's composition bucket, which the controller wrote. On every node; // a node that runs no list writes nothing. Looked at again whenever what runs changed (RunsChanged, Settled). - users := userListFollower(opts, session, func() []delivery.Composed { + users := userListFollower(opts, session, func() []compositions.Composed { if now := current.get(); now != nil { return now.Everything() } @@ -545,7 +546,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus Say: say, // A failure of the bus not answering for a moment is tried again: 2 s, 5 s, 15 s, 60 s, then every 5 min. Backoff: link.Backoff, - Apply: func(ctx context.Context, c delivery.Composed, others []delivery.Composed) agent.Result { + Apply: func(ctx context.Context, c compositions.Composed, others []compositions.Composed) agent.Result { actx, done := applyContext(session.WithLink(ctx)) // While the grants part's list is followed, the controller's list in the bus module's composition is not // written: the apply keeps the list on the machine (ADR 0547 clause 8, never both). @@ -571,7 +572,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus sayFacts(ctx, ag) return res }, - Remove: func(ctx context.Context, module string, others []delivery.Composed) agent.Result { + Remove: func(ctx context.Context, module string, others []compositions.Composed) agent.Result { actx, done := applyContext(session.WithLink(ctx)) res := removeComposition(actx, opts, module, others, ag.Placed(module), sched, say) done() @@ -595,7 +596,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus } hooks := composerHooks{ seatList: seats, - running: func() []delivery.Composed { + running: func() []compositions.Composed { if now := own.get(); now != nil { return runsAsWritten(now.Everything(), opts.state) } @@ -639,8 +640,8 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus shadowHooks.refused, shadowHooks.kept, shadowHooks.certify, shadowHooks.seatList = nil, nil, nil, nil shadowEnv := composerEnv(session, node, say, shadowHooks) shadow := selfcompose.NewShadow(shadowEnv, - func(ctx context.Context, answers map[string]delivery.CandidateAnswer) { - session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers)) + func(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) { + session.NoteCandidates(ctx, compositions.WithValuesDiffer(answers)) }) go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with @@ -712,7 +713,7 @@ func (c *agentNow) get() *agent.Agent { // // removed is the modules whose want says removed: in the node's own apply, a resource of any other module goes only when // its module is among them (issue 774). -func applyComposition(ctx context.Context, opts options, c delivery.Composed, others []delivery.Composed, placed, removed map[string]bool, +func applyComposition(ctx context.Context, opts options, c compositions.Composed, others []compositions.Composed, placed, removed map[string]bool, sched *apply.Scheduler, say link.Announce) agent.Result { name := scopeName(c) if c.Module == "" { @@ -760,7 +761,7 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot } // removeComposition removes one module's own resources and nothing of another's. -func removeComposition(ctx context.Context, opts options, module string, others []delivery.Composed, placed map[string]bool, +func removeComposition(ctx context.Context, opts options, module string, others []compositions.Composed, placed map[string]bool, sched *apply.Scheduler, say link.Announce) agent.Result { scope := apply.Scope{Module: module, Others: resourcesOf(others), Placed: placed} got := applyModuleHeld(ctx, opts, &declaration.Declaration{Version: declaration.Version}, &scope, sched, say) @@ -833,7 +834,7 @@ func keptDeclaration(statePath string, signer ed25519.PublicKey) (*declaration.D // (adoption, identities, groups, libraries) from the node's composition once the agent holds it and from the kept // declaration until then. What the node judges its health by, and what the filling, the take act and the judgement of // directories read. -func whatRuns(everything []delivery.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) { +func whatRuns(everything []compositions.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) { d, err := declarationOf(everything...) if err != nil { return nil, err @@ -919,8 +920,8 @@ func refuseEmptyWrites(d *declaration.Declaration) error { // recordedIn says the engine's applied state holds a record of every resource of a composition: what the agent believes // of a have or the kept declaration only when it does, so a resource removed since is made again (issue 1945). A state // that cannot be read says yes: the agent then believes what it was told, as before. -func recordedIn(statePath string, say func(string)) func(ctx context.Context, c delivery.Composed) bool { - return func(_ context.Context, c delivery.Composed) bool { +func recordedIn(statePath string, say func(string)) func(ctx context.Context, c compositions.Composed) bool { + return func(_ context.Context, c compositions.Composed) bool { known, err := store.Load(statePath) if err != nil { if say != nil { @@ -982,7 +983,7 @@ func applyContext(ctx context.Context) (context.Context, context.CancelFunc) { // composerHooks are what the composer reads of the agent and the machine (selfcompose.Env Running, Unseal, Refused). type composerHooks struct { - running func() []delivery.Composed + running func() []compositions.Composed unseal func(string) ([]byte, error) refused func(why string, held map[string]string) kept func(kept map[string][]string) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index a175086..1d4d9d3 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -7,6 +7,8 @@ import ( "crypto/ed25519" "encoding/json" "errors" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" "strings" @@ -62,7 +64,7 @@ type itBed struct { seatSilent atomic.Bool // seatAsked counts the node's asks of the stand-in seat, answered or refused: the node reached the bus and asked. seatAsked atomic.Int32 - findings nats.KeyValue + findings nats.KeyValue } // aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what @@ -128,12 +130,12 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed { if b.nodeJS, err = b.nodeConn.JetStream(); err != nil { t.Fatal(err) } - composedBucket, _ := delivery.ComposedBucketFor(itNode) + composedBucket, _ := engcomp.BucketFor(itNode) for _, c := range []struct { name string into *nats.KeyValue history uint8 - }{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, delivery.ComposedHistory}, + }{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History}, {delivery.HaveBucket, &b.haves, delivery.HaveHistory}} { kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: c.name, History: c.history}) if err != nil { @@ -255,7 +257,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { within(t, 5*time.Second, "the node did not compose and apply its own module once placed", func() bool { return b.file("web") == "greeting=bonjour\n" }) // ADR 0565 §6 (1953): the node's engine writes its own key of the state packages, under the grant the grants part // composes for it, with each package its compositions declare and the module declaring it; once, while nothing changes. - wantPkgs := `{"node":"` + itNode + `","packages":[{"package":"web-tools","modules":["web"],"absent":false}]}` + wantPkgs := `{"node":"` + itNode + `","packages":[{"absent":false,"modules":["web"],"package":"web-tools"}]}` within(t, 5*time.Second, "the node did not write its key of the state packages", func() bool { e, err := st.packages.Get(itNode) return err == nil && string(e.Value()) == wantPkgs @@ -353,7 +355,7 @@ func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValu } within(t, 10*time.Second, "the node did not refuse a candidate taking its placement away", func() bool { a := node().Candidates["revert-x"] - return a.Proposal == "revert-x" && a.Returns == "" && a.Verdict == delivery.CandidateRefused && + return a.Proposal == "revert-x" && a.Returns == "" && a.Verdict == nodeengine.CandidateAnswerVerdictRefused && strings.Contains(a.Refused, "composes nothing") }) ran := b.have("web").Composed @@ -506,17 +508,17 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str // ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build // its want names, with the digest its have names (the plan left out of it), and the node's own. first := b.have("web").Composed - webKey, _ := delivery.ComposedKey(itNode, "web", fp) + webKey, _ := engcomp.Key(itNode, "web", fp) within(t, 5*time.Second, "the node did not publish web's composition in its composition state", func() bool { e, err := b.composed.Get(webKey) if err != nil { return false } - c, err := delivery.DecodeComposed(e.Value()) + c, err := engcomp.Decode(e.Value()) if err != nil { return false } - d, err := delivery.ComposedDigestOf(c) + d, err := engcomp.DigestOf(c) return err == nil && d == first }) if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil { @@ -543,17 +545,18 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str if err != nil { t.Fatal(err) } - var answer delivery.CandidateAnswer + var answer nodeengine.CandidateAnswer within(t, 10*time.Second, "the node did not answer its candidate", func() bool { e, err := b.haves.Get(hkey) if err != nil { return false } n, err := delivery.DecodeHaveNode(e.Value()) - answer = n.Candidates["feat-x"] + wire, _ := json.Marshal(n.Candidates["feat-x"]) + _ = json.Unmarshal(wire, &answer) return err == nil && answer.Proposal == "feat-x" && n.Record != nil }) - if answer.Verdict != delivery.CandidateSame || answer.Revision != crev || len(answer.Same) != 1 || answer.Same[0] != "web" { + if answer.Verdict != nodeengine.CandidateAnswerVerdictSame || answer.Revision != int64(crev) || len(answer.Same) != 1 || answer.Same[0] != "web" { t.Errorf("the answer to a candidate equal to the placement: %+v", answer) } if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" { @@ -631,7 +634,7 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) { func mustComposedKey(t *testing.T, module, fp string) string { t.Helper() - key, err := delivery.ComposedKey(itNode, module, fp) + key, err := engcomp.Key(itNode, module, fp) if err != nil { t.Fatal(err) } diff --git a/cmd/mesh-host/recorded_test.go b/cmd/mesh-host/recorded_test.go index a1f4476..849e8e9 100644 --- a/cmd/mesh-host/recorded_test.go +++ b/cmd/mesh-host/recorded_test.go @@ -3,11 +3,10 @@ package main import ( "context" "encoding/json" + engcomp "github.com/novox/mesh-host/internal/compositions" "path/filepath" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-host/internal/liveness" "github.com/novox/mesh-host/internal/store" ) @@ -21,7 +20,7 @@ func TestACompositionIsRecordedOnlyWhenEveryResourceIs(t *testing.T) { if err := store.Save(path, s); err != nil { t.Fatal(err) } - c := delivery.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{ + c := engcomp.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{ json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`)}} recorded := recordedIn(path, func(string) {}) if !recorded(context.Background(), c) { @@ -87,7 +86,7 @@ func TestAFilledFileDoesNotMakeItsCompositionUnrecorded(t *testing.T) { if err := store.Save(path, s); err != nil { t.Fatal(err) } - c := delivery.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{ + c := engcomp.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{ json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`), json.RawMessage(`{"id":"ca-trust.filled","type":"file","module":"ca-trust","path":"/etc/ca-filled","mode":"0644","owner-at-runtime":"owned"}`)}} if !recordedIn(path, func(string) {})(context.Background(), c) { diff --git a/cmd/mesh-host/runs_as_written.go b/cmd/mesh-host/runs_as_written.go index a2bca2a..8de19dd 100644 --- a/cmd/mesh-host/runs_as_written.go +++ b/cmd/mesh-host/runs_as_written.go @@ -4,10 +4,10 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/store" ) @@ -20,7 +20,7 @@ import ( // names what is on disk: **only when the node's record says it wrote exactly that content there** (store Applied.Wrote, // Target), never a file someone else changed. A file that cannot be read, or that the record does not vouch for, is // left as the composition names it. -func runsAsWritten(running []delivery.Composed, statePath string) []delivery.Composed { +func runsAsWritten(running []engcomp.Composed, statePath string) []engcomp.Composed { if len(running) == 0 || statePath == "" { return running } @@ -28,7 +28,7 @@ func runsAsWritten(running []delivery.Composed, statePath string) []delivery.Com if err != nil { return running } - out := make([]delivery.Composed, len(running)) + out := make([]engcomp.Composed, len(running)) for i, c := range running { out[i] = c var changed []json.RawMessage diff --git a/cmd/mesh-host/seatlist.go b/cmd/mesh-host/seatlist.go index 6ad0ce2..f9b8072 100644 --- a/cmd/mesh-host/seatlist.go +++ b/cmd/mesh-host/seatlist.go @@ -110,7 +110,7 @@ func (w findingsOfTheNode) encode(f views.Finding) (string, []byte, error) { key := conditions.Key(conditions.ScopeMachine, f.ID, f.Kind) v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning, Subject: condseat.Subject{Scope: conditions.ScopeMachine, ID: f.ID, Machine: f.ID}, Summary: f.Summary, - Headline: f.Headline, Explanation: f.Explanation, Source: "seat-list-unread", At: f.At.UTC().Format(time.RFC3339)} + Headline: f.Headline, Explanation: f.Explanation, Source: "seat-list-unread", At: f.At.UTC()} if err := conditions.CheckFinding(v); err != nil { return "", nil, err } diff --git a/cmd/mesh-host/userlist.go b/cmd/mesh-host/userlist.go index 54cb6f1..8f531fb 100644 --- a/cmd/mesh-host/userlist.go +++ b/cmd/mesh-host/userlist.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" @@ -25,7 +26,7 @@ import ( // userListFollower is the follower of the bus's user list for one link: the node's composition bucket, the keys the // agent trusts, the write alone, and the have. -func userListFollower(opts options, session *link.Session, running func() []delivery.Composed, +func userListFollower(opts options, session *link.Session, running func() []engcomp.Composed, say link.Announce, wrote func()) *userlist.Follower { return userlist.New(userlist.Env{ Node: session.Membership.Node, diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index c756df7..bfbb992 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "encoding/json" "errors" + engcomp "github.com/novox/mesh-host/internal/compositions" "path/filepath" "sort" "strings" @@ -31,7 +32,7 @@ func aFileOf(module, id string) json.RawMessage { func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) { kept := &declaration.Declaration{Identities: []declaration.Identity{{Module: "db", Name: "mesh-db"}}, Libraries: []declaration.Library{{Library: "media"}}} - everything := []delivery.Composed{ + everything := []engcomp.Composed{ {Node: "n1", Module: "web", Resources: []json.RawMessage{aFileOf("web", "a")}}, {Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}}, // still the kept declaration's } @@ -45,7 +46,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) { } // Once the agent holds the node's composition, its parts are the node's. - node := delivery.Composed{Node: "n1", Own: delivery.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}} + node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}} d, err = whatRuns(append(everything, node), true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") }) if err != nil { t.Fatal(err) @@ -88,7 +89,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { for _, name := range row.runs { raws = append(raws, process(name)) } - runs, err := whatRuns([]delivery.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false, + runs, err := whatRuns([]engcomp.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false, func() (*declaration.Declaration, error) { return nil, errors.New("none kept") }) if err != nil { t.Fatal(err) @@ -161,9 +162,9 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { // the module's: the declaration joined of both names each file once, so the filling writes it; the node's own resources // no module declares stay. (Live from 2026-10-25 12:43: "2 resources declare X" on every node.) func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) { - own := delivery.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")}, + own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")}, Filled: []string{"/tmp/r6/zsh/rc"}} - zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}, + zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}, Filled: []string{"/tmp/r6/zsh/rc"}} d, err := declarationOf(own, zsh) if err != nil { @@ -181,7 +182,7 @@ func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) { } // The same at the node's own apply: what the others declare is theirs. - trimmed, dropped := ownWithout(own, []delivery.Composed{zsh}) + trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh}) if len(trimmed.Resources) != 1 || len(dropped) != 1 || dropped[0] != "zsh.rc (zsh)" { t.Errorf("the node's own without zsh's: %d resources, dropped %v", len(trimmed.Resources), dropped) } @@ -211,9 +212,9 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) { strings.Join(filled[agent.NodeModule], ",") != "/etc/motd" || len(filled["zsh"]) != 0 { t.Fatalf("filled by module: %v", filled) } - cc := delivery.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]} - node := delivery.Composed{Node: "n1"} - d, err := whatRuns([]delivery.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") }) + cc := engcomp.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]} + node := engcomp.Composed{Node: "n1"} + d, err := whatRuns([]engcomp.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") }) if err != nil || !d.Fills("/etc/cc.json") { t.Fatalf("what runs does not name claude-code's file filled: %v (%v)", d, err) } @@ -226,9 +227,9 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) { // unit or a container of the same name) is not the module's, and is neither dropped nor left to be removed. func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) { ownFile, _ := json.Marshal(map[string]any{"id": "host.zshrc", "type": "file", "path": "/tmp/r6/zsh/rc", "content": "x"}) - own := delivery.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}} - zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}} - trimmed, dropped := ownWithout(own, []delivery.Composed{zsh}) + own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}} + zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}} + trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh}) if len(dropped) != 0 || len(trimmed.Resources) != 1 { t.Errorf("a resource sharing only its target was dropped: %v", dropped) } diff --git a/go.mod b/go.mod index 17ac313..e5ee449 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f github.com/nats-io/nats-server/v2 v2.11.17 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 198a966..a80bd7b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -126,9 +126,9 @@ type Env struct { // Apply applies one module's composition (scope NodeModule for the node's own), given the compositions the node's // other modules run so a path, unit or group they hold is not removed or taken. It is called with one module at a // time. - Apply func(ctx context.Context, c delivery.Composed, others []delivery.Composed) Result + Apply func(ctx context.Context, c compositions.Composed, others []compositions.Composed) Result // Remove removes the module's own resources and nothing of another's. - Remove func(ctx context.Context, module string, others []delivery.Composed) Result + Remove func(ctx context.Context, module string, others []compositions.Composed) Result // Backup backs a module up before a new composition of it is applied; nil backs up nothing. Backup func(ctx context.Context, module string) Backup // Kept is the resources the node last applied, by module, from the declaration it kept before the switch (ADR 0539 @@ -143,12 +143,12 @@ type Env struct { KeptFilled func() map[string][]string // Recorded says the engine holds a record of every resource of the composition, so it was applied and not removed // since; nil says yes. A composition the kept declaration or a have says runs is applied again when it says no. - Recorded func(ctx context.Context, c delivery.Composed) bool + Recorded func(ctx context.Context, c compositions.Composed) bool // Inactive says a resource the composition declares running is not active on the machine now (a unit stopped, a // container not running), read from the machine and not from any record (ADR 0568 §1). A have or a kept declaration // that says the module runs is believed at the agent's start only when this says no; nil says no, and an unreadable // machine says no (unknown is not inactive). - Inactive func(ctx context.Context, c delivery.Composed) bool + Inactive func(ctx context.Context, c compositions.Composed) bool // Revisions are a composition key's revisions the bucket keeps, oldest first; nil reads none. The composition a want // names is taken from them when a newer one replaced it under the same key (ADR 0539 clause 1). Revisions func(ctx context.Context, key string) ([]Update, error) @@ -282,7 +282,7 @@ type wantNow struct { type compNow struct { Raw []byte - C delivery.Composed + C compositions.Composed Dig string Rev uint64 Err error @@ -722,7 +722,7 @@ func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) compo // Running is the compositions the node runs now: each module's that the agent applied or found already running, and // the node's own. What the node judges its health by and says of itself is built from them. -func (a *Agent) Running() []delivery.Composed { +func (a *Agent) Running() []compositions.Composed { a.mu.Lock() defer a.mu.Unlock() mods := make([]string, 0, len(a.running)) @@ -730,7 +730,7 @@ func (a *Agent) Running() []delivery.Composed { mods = append(mods, m) } sort.Strings(mods) - var out []delivery.Composed + var out []compositions.Composed for _, m := range mods { h := a.running[m] if h.Phase == delivery.PhaseRemoved { @@ -781,7 +781,7 @@ func (a *Agent) takeWant(u Update) string { // takeComposition takes a change of a composition key and returns the module it concerns. func (a *Agent) takeComposition(u Update) string { - node, module, _, isNode, err := delivery.ParseComposedKey(u.Key) + node, module, _, isNode, err := compositions.ParseKey(u.Key) if err != nil || node != a.env.Node { return "" } @@ -795,14 +795,14 @@ func (a *Agent) takeComposition(u Update) string { return module } now := compNow{Raw: u.Value, Rev: u.Revision} - c, err := delivery.DecodeComposed(u.Value) + c, err := compositions.Decode(u.Value) if err == nil { // The node composed it itself: it is applied as composed, never verified against a signer, since no composition // read from the bus is applied (novox/hq ADR 0547 §2, ADR 0593 §6). now.C = c - now.Dig, err = delivery.ComposedDigestOf(c) + now.Dig, err = compositions.DigestOf(c) if err != nil { - now.C = delivery.Composed{} + now.C = compositions.Composed{} } } else { a.sayOnce("decode:"+u.Key, fmt.Sprintf("the composition %s cannot be read, and nothing of it is applied: %v", u.Key, err)) @@ -844,9 +844,9 @@ func (a *Agent) compositionFor(module, fp string) (compNow, bool) { var key string var err error if module == NodeModule { - key, err = delivery.ComposedNodeKey(a.env.Node) + key, err = compositions.NodeKey(a.env.Node) } else { - key, err = delivery.ComposedKey(a.env.Node, module, fp) + key, err = compositions.Key(a.env.Node, module, fp) } if err != nil { return compNow{}, false @@ -879,8 +879,8 @@ func (a *Agent) revisionNamed(ctx context.Context, module, fp, digest string) (c // others are what the node's other modules run now, so what they hold is not taken: the compositions the agent runs and, // for every module the agent does not hold yet, the resources the kept declaration has for it (ADR 0429 §4). -func (a *Agent) others(except string) []delivery.Composed { - var out []delivery.Composed +func (a *Agent) others(except string) []compositions.Composed { + var out []compositions.Composed mods := make([]string, 0, len(a.running)) for m := range a.running { if m != except { @@ -1011,7 +1011,7 @@ func (a *Agent) loadApplied(ctx context.Context) { if rs, ok := a.env.Kept()[NodeModule]; ok && len(rs) > 0 { c, _ := a.keptComposed(NodeModule, rs) c.Module = "" - raw, eerr := delivery.EncodeComposed(c) + raw, eerr := compositions.Encode(c) if eerr == nil { eerr = a.env.Applied.Keep(NodeModule, raw) } @@ -1035,10 +1035,10 @@ func (a *Agent) loadApplied(ctx context.Context) { if ctx.Err() != nil { return } - c, err := delivery.DecodeComposed(kept[m]) + c, err := compositions.Decode(kept[m]) var dig string if err == nil { - dig, err = delivery.ComposedDigestOf(c) + dig, err = compositions.DigestOf(c) } if err != nil { a.env.Say(fmt.Sprintf("the composition this engine kept of %s cannot be read, so what runs of it is read from its have: %v", m, err)) @@ -1077,7 +1077,7 @@ func (a *Agent) keepApplied(module string, comp compNow) { raw := comp.Raw if len(raw) == 0 { var err error - if raw, err = delivery.EncodeComposed(comp.C); err != nil { + if raw, err = compositions.Encode(comp.C); err != nil { a.env.Say(fmt.Sprintf("the composition %s was applied from cannot be kept: %v", module, err)) return } @@ -1126,7 +1126,7 @@ func (a *Agent) unreadableModules() []string { } current := map[string]pick{} for key, c := range a.comps { - _, m, fp, isNode, err := delivery.ParseComposedKey(key) + _, m, fp, isNode, err := compositions.ParseKey(key) if err != nil || isNode { continue } @@ -1159,7 +1159,7 @@ func (a *Agent) unreadableModules() []string { // until that one is applied, so the node's own apply never removes them in between (issue 1945, live on 2026-10-25: // shanks lost systemd-resolved's guard while its composition waited for a credential). The composition its want names, // else its newest. Not part of Everything: what has not been applied is not what runs. -func (a *Agent) heldOut(except string) []delivery.Composed { +func (a *Agent) heldOut(except string) []compositions.Composed { out := a.others(except) held := map[string]bool{} for _, c := range out { @@ -1167,7 +1167,7 @@ func (a *Agent) heldOut(except string) []delivery.Composed { } waiting := map[string]compNow{} for key, c := range a.comps { - _, m, fp, isNode, err := delivery.ParseComposedKey(key) + _, m, fp, isNode, err := compositions.ParseKey(key) if err != nil || isNode || c.Err != nil || m == except || held[m] { continue } @@ -1203,7 +1203,7 @@ func (a *Agent) heldOut(except string) []delivery.Composed { if len(a.heldAlone) == 0 { return out } - var own *delivery.Composed + var own *compositions.Composed for _, c := range a.others("\x00") { if c.Module == "" { own = &c @@ -1237,7 +1237,7 @@ func (a *Agent) heldOut(except string) []delivery.Composed { } if len(rs) > 0 { // Held out, never judged: its Data from its manifest where known, as every other composition says it. - out = append(out, delivery.Composed{Node: a.env.Node, Module: m, Resources: rs, Data: a.keptData[m]}) + out = append(out, compositions.Composed{Node: a.env.Node, Module: m, Resources: rs, Data: a.keptData[m]}) } } return out @@ -1246,7 +1246,7 @@ func (a *Agent) heldOut(except string) []delivery.Composed { // Everything is what the node runs: the compositions the agent runs, and the kept declaration's resources for each module // the agent does not hold yet. What the node judges its health by, and what the filling, the take act and the judgement // of directories read, is built from it. -func (a *Agent) Everything() []delivery.Composed { +func (a *Agent) Everything() []compositions.Composed { a.mu.Lock() defer a.mu.Unlock() return a.others("\x00") @@ -1550,7 +1550,7 @@ func (a *Agent) inactiveAtStart(ctx context.Context, module string, comp compNow return true } -func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNow, others []delivery.Composed) { +func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNow, others []compositions.Composed) { fp, dig := w.Want.FP, w.Want.Composed a.mu.Lock() _, ranBefore := a.running[module] @@ -1840,7 +1840,7 @@ func (a *Agent) buildAlone(ctx context.Context, module string, prev delivery.Hav // The composition its have named at the start comes before the kept declaration (issue 924). From the kept declaration, // what runs carries Data as its manifest declares it; a module whose manifest could not be read cannot be judged against // it (false), and that is said, rather than taking it to hold no data and judging a false move of its data. -func (a *Agent) runningComposition(module string) (delivery.Composed, bool) { +func (a *Agent) runningComposition(module string) (compositions.Composed, bool) { if c, ok := a.applied[module]; ok && c.Err == nil { return c.C, true } @@ -1855,14 +1855,14 @@ func (a *Agent) runningComposition(module string) (delivery.Composed, bool) { } return c, known } - return delivery.Composed{}, false + return compositions.Composed{}, false } // keptComposed is what runs of a module from the kept declaration: its resources there, the paths it names as filled, // and Data as the module's manifest declares it; false when the manifest could not be read, so Data is not known. Called // with a.mu held. -func (a *Agent) keptComposed(module string, rs []json.RawMessage) (delivery.Composed, bool) { - c := delivery.Composed{Node: a.env.Node, Module: module, Resources: rs} +func (a *Agent) keptComposed(module string, rs []json.RawMessage) (compositions.Composed, bool) { + c := compositions.Composed{Node: a.env.Node, Module: module, Resources: rs} if a.env.KeptFilled != nil { c.Filled = a.env.KeptFilled()[module] } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 4431c3b..c34699a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -123,7 +123,7 @@ type bed struct { during func() backup func(module string) Backup kept map[string][]json.RawMessage - others [][]delivery.Composed + others [][]compositions.Composed said []string // settled is each module Settled was told, and settledRan whether what runs held it then. settled []string @@ -135,7 +135,7 @@ type bed struct { func newBed(t *testing.T) *bed { b := &bed{t: t, wants: newFeed(), comps: newFeed(), haves: &haves{}} b.env = Env{Node: node, Wants: b.wants, Composed: b.comps, Haves: b.haves, - Apply: func(_ context.Context, c delivery.Composed, others []delivery.Composed) Result { + Apply: func(_ context.Context, c compositions.Composed, others []compositions.Composed) Result { if b.during != nil { b.during() } @@ -156,7 +156,7 @@ func newBed(t *testing.T) *bed { } return Result{} }, - Remove: func(_ context.Context, m string, _ []delivery.Composed) Result { + Remove: func(_ context.Context, m string, _ []compositions.Composed) Result { b.mu.Lock() defer b.mu.Unlock() b.removed = append(b.removed, m) @@ -222,13 +222,13 @@ func file(module, id, content string) json.RawMessage { // composition is a module's composition at a fingerprint: its key, its bytes and its digest, from the writer's code. func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessage) (key string, raw []byte, digest string) { t.Helper() - c := delivery.Composed{Node: node, Module: module, FP: fp, Data: data, Resources: rs} + c := compositions.Composed{Node: node, Module: module, FP: fp, Data: data, Resources: rs} var err error if module == NodeModule { c.Module, c.FP = "", "" - key, err = delivery.ComposedNodeKey(node) + key, err = compositions.NodeKey(node) } else { - key, err = delivery.ComposedKey(node, module, fp) + key, err = compositions.Key(node, module, fp) } if err != nil { t.Fatal(err) @@ -237,13 +237,13 @@ func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessa } // encodedAs is c as the node's composer hands it: unsigned (ADR 0593 §6), its key, its bytes and its digest. -func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, string) { +func encodedAs(t *testing.T, c compositions.Composed, key string) (string, []byte, string) { t.Helper() - raw, err := delivery.EncodeComposed(c) + raw, err := compositions.Encode(c) if err != nil { t.Fatal(err) } - digest, err := delivery.ComposedDigestOf(c) + digest, err := compositions.DigestOf(c) if err != nil { t.Fatal(err) } @@ -987,20 +987,20 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { b.env.Applied = store // A folded module's resource in the node's own composition: its id names the module, as the controller folds it. natsConf, _ := json.Marshal(map[string]any{"id": "nats.conf", "type": "file", "path": "/tmp/r6/nats/conf", "content": "listen 4222"}) - own := delivery.Composed{Node: node, Resources: []json.RawMessage{natsConf}} - ownRaw, err := delivery.EncodeComposed(own) + own := compositions.Composed{Node: node, Resources: []json.RawMessage{natsConf}} + ownRaw, err := compositions.Encode(own) if err != nil { t.Fatal(err) } - ownDig, _ := delivery.ComposedDigestOf(own) - web := delivery.Composed{Node: node, Module: "web", FP: fpOf("a"), Resources: []json.RawMessage{file("web", "x", "1")}} - webRaw, _ := delivery.EncodeComposed(web) - webDig, _ := delivery.ComposedDigestOf(web) + ownDig, _ := compositions.DigestOf(own) + web := compositions.Composed{Node: node, Module: "web", FP: fpOf("a"), Resources: []json.RawMessage{file("web", "x", "1")}} + webRaw, _ := compositions.Encode(web) + webDig, _ := compositions.DigestOf(web) store.kept[NodeModule], store.kept["web"] = ownRaw, webRaw b.haves.Write(context.Background(), Have{Module: NodeModule, Composed: ownDig}) b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(delivery.PhaseRunning)}) // The composer's first pass reads what runs when its watch begins, before the agent reads any have. - firstCh := make(chan []delivery.Composed, 1) + firstCh := make(chan []compositions.Composed, 1) comps := b.comps var ag *Agent b.env.Composed = watchFunc(func(ctx context.Context, p string) (<-chan Update, error) { @@ -1013,7 +1013,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { done := make(chan struct{}) go func() { defer close(done); _ = ag.Run(ctx) }() defer func() { cancel(); <-done }() - var first []delivery.Composed + var first []compositions.Composed select { case first = <-firstCh: case <-time.After(5 * time.Second): @@ -1029,9 +1029,9 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { ranOwn, ranWeb, len(first)) } // An apply keeps the composition it applied; a removal forgets it. - web2 := delivery.Composed{Node: node, Module: "web", FP: fpOf("b"), Resources: []json.RawMessage{file("web", "x", "2")}} - ck, _ := delivery.ComposedKey(node, "web", fpOf("b")) - raw2, _ := delivery.EncodeComposed(web2) + web2 := compositions.Composed{Node: node, Module: "web", FP: fpOf("b"), Resources: []json.RawMessage{file("web", "x", "2")}} + ck, _ := compositions.Key(node, "web", fpOf("b")) + raw2, _ := compositions.Encode(web2) comps.put(ck, raw2) wk, _ := delivery.WantKey(node, "web") wraw, _ := delivery.EncodeWant(delivery.Want{FP: fpOf("b"), Self: true}) @@ -1050,10 +1050,10 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { store := &memApplied{kept: map[string][]byte{}} h := &haves{} h.Write(context.Background(), Have{Module: NodeModule, Composed: "sha256:" + strings.Repeat("c", 64)}) - firstPass := func(kept map[string][]json.RawMessage) []delivery.Composed { + firstPass := func(kept map[string][]json.RawMessage) []compositions.Composed { b := newBed(t) b.env.SelfComposed, b.kept, b.env.Applied, b.env.Haves = true, kept, store, h - firstCh := make(chan []delivery.Composed, 1) + firstCh := make(chan []compositions.Composed, 1) comps := b.comps var ag *Agent b.env.Composed = watchFunc(func(ctx context.Context, p string) (<-chan Update, error) { @@ -1073,7 +1073,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { } return nil } - runsNats := func(cs []delivery.Composed) bool { + runsNats := func(cs []compositions.Composed) bool { for _, c := range cs { if c.Module == "" && len(c.Resources) == 1 && strings.Contains(string(c.Resources[0]), "4222") { return true @@ -1281,7 +1281,7 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) { // it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads. func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) { b := newBed(t) - bad, err := delivery.ComposedKey(node, "broken", fpOf("c")) + bad, err := compositions.Key(node, "broken", fpOf("c")) if err != nil { t.Fatal(err) } @@ -1317,7 +1317,7 @@ func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) { // compositions written before the filled paths, which the SDK now refuses, and nobody applies them). func TestAStaleRefusedCompositionDoesNotHoldTheNodesOwnBack(t *testing.T) { b := newBed(t) - stale, err := delivery.ComposedKey(node, "power", fpOf("a")) + stale, err := compositions.Key(node, "power", fpOf("a")) if err != nil { t.Fatal(err) } @@ -1353,7 +1353,7 @@ func TestACompositionWhoseResourcesWereRemovedIsAppliedAgain(t *testing.T) { } else { b.haves.stored = map[string]Have{"ca-trust": {Module: "ca-trust", FP: fp, Composed: dig, Phase: "running"}} } - b.env.Recorded = func(context.Context, delivery.Composed) bool { return false } + b.env.Recorded = func(context.Context, compositions.Composed) bool { return false } stop := b.run() defer stop() eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 }) @@ -1402,8 +1402,8 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) { `","mode":"0644","owner-at-runtime":"owned"}`) } fp := fpOf("a") - ck, _ := delivery.ComposedKey(node, "power", fp) - applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true, + ck, _ := compositions.Key(node, "power", fp) + applied := compositions.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true, Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}} _, appliedRaw, appliedDig := encodedAs(t, applied, ck) newer := applied @@ -1527,13 +1527,13 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( stop := b.run() defer stop() unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) { - c := delivery.Composed{Node: node, Module: module, FP: fp, Resources: rs} - key, _ := delivery.ComposedKey(node, module, fp) - raw, err := delivery.EncodeComposed(c) + c := compositions.Composed{Node: node, Module: module, FP: fp, Resources: rs} + key, _ := compositions.Key(node, module, fp) + raw, err := compositions.Encode(c) if err != nil { t.Fatal(err) } - dig, _ := delivery.ComposedDigestOf(c) + dig, _ := compositions.DigestOf(c) return key, raw, dig } want := func(module, fp, yes string) { @@ -1567,10 +1567,10 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // The node's own composition carries its sealed values in its key, as a module's have does, so a restart reuses its // core boxes and applies nothing again (review of L2, round 3, finding 1). box, _ := json.Marshal(map[string]any{"id": "core-box", "type": "file", "path": "/tmp/r6/core-box", "sealed": "core"}) - own := delivery.Composed{Node: node, Resources: []json.RawMessage{box}} - nk, _ := delivery.ComposedNodeKey(node) - nraw, _ := delivery.EncodeComposed(own) - ndig, _ := delivery.ComposedDigestOf(own) + own := compositions.Composed{Node: node, Resources: []json.RawMessage{box}} + nk, _ := compositions.NodeKey(node) + nraw, _ := compositions.Encode(own) + ndig, _ := compositions.DigestOf(own) b.comps.put(nk, nraw) nwk, _ := delivery.WantNodeKey(node) nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) @@ -1707,8 +1707,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( } // Issue 774 (ace, 2026-11-04 01:29): the node's own revision shows its own fields, the provisioner client's account // and the adoption among them, and what the composer's last pass held. - ownKey, ownRaw, ownDig := encodedAs(t, delivery.Composed{Node: node, Own: delivery.NodeOwn{Provisioner: "mesh-provisioner-client", - Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := delivery.ComposedNodeKey(node); return k }()) + ownKey, ownRaw, ownDig := encodedAs(t, compositions.Composed{Node: node, Own: compositions.NodeOwn{Provisioner: "mesh-provisioner-client", + Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := compositions.NodeKey(node); return k }()) keep(ownKey, ownRaw) if view, err := b.ag.Composition(context.Background(), NodeModule, ownDig, false); err != nil || view.Own == nil || view.Own.Provisioner != "mesh-provisioner-client" || view.Own.Adoption == nil || view.Own.Adoption.Taken[0] != "web" || @@ -1717,9 +1717,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( } // novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads // that revision by the digest its have and want name, its resources' (the plan left out of the digest). - planned, _ := delivery.DecodeComposed(draw) + planned, _ := compositions.Decode(draw) planned.Plan = &catalogue.Plan{} - praw, err := delivery.EncodeComposed(planned) + praw, err := compositions.Encode(planned) if err != nil { t.Fatal(err) } @@ -1823,7 +1823,7 @@ func testAnEngineStartReconcilesAModuleFromTheMachinesFacts(t *testing.T) { } var mu sync.Mutex asked := false - b.env.Inactive = func(context.Context, delivery.Composed) bool { + b.env.Inactive = func(context.Context, compositions.Composed) bool { mu.Lock() defer mu.Unlock() asked = true diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 11d00e5..a3f86f4 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" @@ -45,7 +46,7 @@ type CompositionView struct { Own *OwnView `json:"own,omitempty"` } -// OwnView is the node's own fields of a composition (delivery.NodeOwn) beside what runs of them, and what the node's +// OwnView is the node's own fields of a composition (engcomp.NodeOwn) beside what runs of them, and what the node's // composer held in its last pass. type OwnView struct { // Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as @@ -218,7 +219,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo view.Composition.Refused = read.Err.Error() return view, nil } - var running *delivery.Composed + var running *engcomp.Composed if known { running = &from rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP} @@ -255,9 +256,9 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo // compositionKey is a module's composition key at a build, or the node's own. func (a *Agent) compositionKey(module, fp string) (string, error) { if module == NodeModule { - return delivery.ComposedNodeKey(a.env.Node) + return engcomp.NodeKey(a.env.Node) } - return delivery.ComposedKey(a.env.Node, module, fp) + return engcomp.Key(a.env.Node, module, fp) } // revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR @@ -276,12 +277,12 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) if u.Deleted || u.Key != key { continue } - c, err := delivery.DecodeComposed(u.Value) + c, err := engcomp.Decode(u.Value) if err != nil { out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err}) continue } - dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6) + dig, err := engcomp.DigestOf(c) // unsigned: a record, never verified (ADR 0593 §6) out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err}) } return out, nil diff --git a/internal/compose/composed.go b/internal/compose/composed.go index 024f287..c30b539 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "sort" "strings" @@ -17,14 +18,14 @@ import ( // Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as // the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own -// (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads. +// (`.node`), by key (engcomp.Key). Each resource says its module, whatever the node reported it reads. // A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module // gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the // private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own, // their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed // writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned // (ADR 0593 §6) and not yet by digest (Composed.ByDigest). -func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { +func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped []string, err error) { out, skipped, _, err = compositions(in, node, fps, mode, false) return out, skipped, err } @@ -41,7 +42,7 @@ const BusUsersStandIn = "the bus's user list" // 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with // ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list // (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself. -func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) { +func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]engcomp.Composed, []string, error) { out, skipped, _, err := compositions(in, node, fps, Reading, true) return out, skipped, err } @@ -52,14 +53,14 @@ func CompositionsApplied(in Input, node string, fps map[string]string) (map[stri // the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a // module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes // as CompositionsApplied composes it: the modes differ only in an own secret never issued. -func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) { +func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]engcomp.Composed, skipped, stood []string, err error) { return compositions(in, node, fps, Foreseeing, false) } // CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user // list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses // before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126). -func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) { +func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped, stood []string, err error) { return compositions(in, node, fps, mode, false) } @@ -72,7 +73,7 @@ func Folds(inOwn bool, fp string) bool { return inOwn && !strings.HasPrefix(fp, delivery.SourcePrefix2) } -func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) { +func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]engcomp.Composed, skipped, stood []string, err error) { reading := in reading.Nodes = append([]Node(nil), in.Nodes...) found := false @@ -102,7 +103,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli // (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published // ports, the paths it owns) is what runs, and its resources are then the ones the node runs. carried := map[string]bool{} - var runsOwn *delivery.Composed + var runsOwn *engcomp.Composed for i := range reading.Nodes { n := &reading.Nodes[i] if n.Name != node || n.RunsOwn == nil { @@ -264,13 +265,13 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli filled = append(filled, p) } } - out, err = delivery.SplitComposed(node, kept, fps, filled, - delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner}) + out, err = engcomp.Split(node, kept, fps, filled, + engcomp.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner}) if err != nil { return nil, nil, nil, err } if len(ownFilled) > 0 { - own, err := delivery.ComposedNodeKey(node) + own, err := engcomp.NodeKey(node) if err != nil { return nil, nil, nil, err } @@ -296,7 +297,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli } // RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names. -func RunsAnyOf(runs *delivery.Composed, module string) bool { +func RunsAnyOf(runs *engcomp.Composed, module string) bool { if runs == nil { return false } @@ -324,7 +325,7 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool { // (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads // nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build // (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process. -func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { +func ToolsRunning(running []engcomp.Composed) map[string][]catalogue.Bundle { type resource struct { ID string `json:"id"` Module string `json:"module"` @@ -476,7 +477,7 @@ func Stub(m catalogue.Manifest) catalogue.Manifest { // is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that // run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none // of is its Stub, as before. -func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest { +func Carried(m catalogue.Manifest, module string, runs *engcomp.Composed) catalogue.Manifest { if runs == nil { return Stub(m) } @@ -521,7 +522,7 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal // carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as // it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is // not among them; a file among them is a folded path. -func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) { +func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *engcomp.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) { idOf := func(raw json.RawMessage) (string, string, string, string, error) { var head struct { ID string `json:"id"` diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index 09dacb5..ccbb3d4 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -2,6 +2,7 @@ package compose import ( "encoding/json" + engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "strings" "testing" @@ -36,14 +37,14 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { } var got []string for key, c := range out { - node, module, fp, isNode, err := delivery.ParseComposedKey(key) + node, module, fp, isNode, err := engcomp.ParseKey(key) if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) { t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err) } if !isNode && fp != fps[module] { t.Errorf("%s is composed at %s, given %s", module, fp, fps[module]) } - if _, err := delivery.EncodeComposed(c); err != nil { + if _, err := engcomp.Encode(c); err != nil { t.Errorf("%s: %v", key, err) } for _, raw := range c.Resources { @@ -81,7 +82,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { } } all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing) - nodeKey, _ := delivery.ComposedNodeKey("laptop") + nodeKey, _ := engcomp.NodeKey("laptop") if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) { t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want)) } @@ -92,7 +93,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"]) } } - if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil { + if _, err := engcomp.Encode(all[nodeKey]); err != nil { t.Errorf("the node's own with the folded modules: %v", err) } if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil { @@ -121,35 +122,35 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { t.Fatal(err) } served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools" - runtimeRuns := func(dropped bool) delivery.Composed { + runtimeRuns := func(dropped bool) engcomp.Composed { env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"` if dropped { env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29) } - return delivery.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage( + return engcomp.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage( `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}} } lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + `","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}` for _, row := range []struct { name string - set func(n *Node) delivery.Composed // and what runs of lamp + set func(n *Node) engcomp.Composed // and what runs of lamp fps map[string]string dropped bool }{ - {"folded at a build of the old form", func(n *Node) delivery.Composed { + {"folded at a build of the old form", func(n *Node) engcomp.Composed { n.InOwn = map[string]bool{"lamp": true} - n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} return *n.RunsOwn }, map[string]string{}, false}, - {"folded, the runtime already dropped it", func(n *Node) delivery.Composed { + {"folded, the runtime already dropped it", func(n *Node) engcomp.Composed { n.InOwn = map[string]bool{"lamp": true} - n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} return *n.RunsOwn }, map[string]string{}, true}, - {"held, its new build not delivered", func(n *Node) delivery.Composed { + {"held, its new build not delivered", func(n *Node) engcomp.Composed { n.Held = map[string]bool{"lamp": true} - lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage( + lr := engcomp.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage( strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}} n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)} return lr @@ -161,7 +162,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { anchor.Assigned = append(anchor.Assigned, "artifacts", "node-tools", "lamp") tools.BusMinted = []string{"anchor." + "node-tools"} anchor.SealingKey = keyOf(t, "anchor") - anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)}) + anchor.RunsTools = ToolsRunning([]engcomp.Composed{runtimeRuns(row.dropped), row.set(anchor)}) row.fps[overlay.Name], row.fps["artifacts"], row.fps["node-tools"] = fpFor('a'), fpFor('5'), fpFor('e') out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing) if err != nil { diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index 14b1b93..be62825 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "maps" "regexp" @@ -27,11 +28,11 @@ import ( // composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own // secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other // stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own). -func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer { - a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision} - refuse := func(err error) delivery.CandidateAnswer { +func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []compositions.Composed, proposal string, revision uint64) nodeengine.CandidateAnswer { + a := nodeengine.CandidateAnswer{Proposal: proposal, Revision: int64(revision)} + refuse := func(err error) nodeengine.CandidateAnswer { a.Refused = err.Error() - a.Verdict = delivery.CandidateRefused + a.Verdict = nodeengine.CandidateAnswerVerdictRefused return a } trunk, err := r.Placements(ctx) @@ -99,16 +100,16 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st if _, whole := failed[delivery.HaveNodeWord]; whole { // Nothing composed to compare: every refusal is said, and nothing else. for _, m := range sortedKeys(failed) { - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) + a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]}) } heldAsToday(&a, s.HeldToday) - a.Verdict = delivery.VerdictOf(a) + a.Verdict = compositions.VerdictOf(a) return a } // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531). if len(s.InOwn) > 0 { - var judged []delivery.Composed + var judged []compositions.Composed for _, c := range running { if c.Module == "" || !slices.Contains(s.InOwn, c.Module) { judged = append(judged, c) @@ -126,7 +127,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st budget -= n kept = append(kept, b) } else { - kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why}) + kept = append(kept, nodeengine.CandidateBodies{ID: b.ID, Why: b.Why}) a.Note = "some differing resources are named without their bodies: the answer holds at most " + fmt.Sprint(MaxShown) + " bytes of them" } @@ -137,8 +138,8 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st notFailed := func(m string) bool { _, ok := failed[m]; return !ok } a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) }) a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) }) - a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) }) - cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) }) + a.Differs = slices.DeleteFunc(cmp.Differs, func(d nodeengine.CandidateDiffers) bool { return !notFailed(d.Module) }) + cmp.Added = slices.DeleteFunc(cmp.Added, func(d nodeengine.CandidateAdded) bool { return !notFailed(d.Module) }) skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) }) // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node. @@ -177,12 +178,12 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st refusals[module] = append(refusals[module], x) } } - addedHere := map[string]delivery.ModuleAdded{} + addedHere := map[string]nodeengine.CandidateAdded{} for _, m := range cmp.Added { addedHere[m.Module] = m } for _, m := range skipped { // placed, and no build given to compose it at - addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}} + addedHere[m] = nodeengine.CandidateAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}} } for m, xs := range unissued { got := addedHere[m] @@ -200,14 +201,14 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st if _, isFailed := failed[m]; isFailed { continue // said below, as why it could not be composed } - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")}) + a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")}) } for _, m := range sortedKeys(failed) { - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) + a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]}) } sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module }) heldAsToday(&a, s.HeldToday) - a.Verdict = delivery.VerdictOf(a) + a.Verdict = compositions.VerdictOf(a) return a } @@ -215,7 +216,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st // held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue // 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No // module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered. -func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) { +func heldAsToday(a *nodeengine.CandidateAnswer, today map[string]string) { if len(today) == 0 { return } @@ -237,7 +238,7 @@ func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) { // maxCulprits bounds the modules one refusal is tried on, each a whole composition. const maxCulprits = 8 -// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies). +// MaxShown bounds the bytes of shown resource bodies in one answer (nodeengine.CandidateDiffers.Bodies). const MaxShown = 256 << 10 var ( @@ -291,13 +292,13 @@ func standsInFor(x, node string) (string, string) { // else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision // (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account, // and the hold says so. -func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string, - ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) { +func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []compositions.Composed, failed map[string]string, + ran func(module string) (catalogue.Manifest, bool)) (out map[string]compositions.Composed, skipped, stood []string, err error) { mode := compose.Foreseeing if applied { mode = compose.Reading } - runs := map[string]*delivery.Composed{} + runs := map[string]*compositions.Composed{} for i := range running { if m := running[i].Module; m != "" { runs[m] = &running[i] @@ -543,7 +544,7 @@ func stubbed(in compose.Input, node, module, leave string) compose.Input { // its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes // and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's // repository on a node it never ran on) is no refusal of the rest. -func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input { +func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *compositions.Composed) compose.Input { out := in out.Nodes = append([]compose.Node(nil), in.Nodes...) for i := range out.Nodes { diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 113c4f0..134e3ae 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "reflect" "slices" @@ -76,7 +78,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { for _, r := range a.Refusals { refused = append(refused, r.Module) } - if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) { + if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) { t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a) } for _, r := range a.Refusals { @@ -86,27 +88,31 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want) } } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { + var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves + if wire, err := json.Marshal(a); err != nil || json.Unmarshal(wire, &wireAnswer) != nil { + t.Fatalf("the answer does not read back: %v", err) + } + if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": wireAnswer}}); err != nil { t.Errorf("the answer is not one a have holds: %v (%s)", err, raw) } - if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK { + if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK { t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a) } // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every // refused module held today the verdict is no refusal, and each is named under Held. today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { + if today.Verdict == nodeengine.CandidateAnswerVerdictRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { t.Errorf("held as today: %+v", today) } // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}}, map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { + if part.Verdict != nodeengine.CandidateAnswerVerdictRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { t.Errorf("held as today in part: %+v", part) } // No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold. - if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"}, + if a := (nodeengine.CandidateAnswer{Refusals: []nodeengine.CandidateRefusal{{Module: "de-spiegel", Why: "a value is unset"}, {Module: "mailu", Why: "no build"}}}); true { heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"}) if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 { @@ -136,14 +142,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { t.Errorf("a refusal naming no placed module was laid on %s", m) } - ownKey, _ := delivery.ComposedNodeKey("n1") + ownKey, _ := engcomp.NodeKey("n1") own := base[ownKey] carriedRes := []json.RawMessage{ json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} // They run after the node's first own resource. own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...) - var running []delivery.Composed + var running []engcomp.Composed for k, c := range base { if k == ownKey { c = own @@ -164,26 +170,26 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn) } a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3) - if a.Verdict != delivery.CandidateSame { + if a.Verdict != nodeengine.CandidateAnswerVerdictSame { t.Errorf("the fold's answer: %+v, want the same", a) } // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). - keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 { + keptToo := append(append([]engcomp.Composed{}, running...), engcomp.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame || len(a.Removed) != 0 { t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) } // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed // the same, not 'in what runs, not composed' (seed #531, novox). membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`) - withMembership := make([]delivery.Composed, 0, len(running)) + withMembership := make([]engcomp.Composed, 0, len(running)) for _, c := range running { if c.Module == "" { c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership) } withMembership = append(withMembership, c) } - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame { + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame { t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) } // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. @@ -309,7 +315,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if err != nil { t.Fatal(err) } - f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ + f1runs := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{ json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), @@ -385,7 +391,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a) } for _, m := range []string{"lemurs", "mailu", "proxy"} { - if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) { + if !slices.ContainsFunc(a.Added, func(x nodeengine.CandidateAdded) bool { return x.Module == m }) { t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a) } } @@ -425,11 +431,11 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held) } for _, m := range []string{"lemurs", "mailu", "proxy"} { - if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m { + if key, _ := engcomp.Key("n1", m, fps[m]); got[key].Module != m { t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got)) } } - carries := func(c delivery.Composed, what string) { + carries := func(c engcomp.Composed, what string) { t.Helper() ownRes, _ := json.Marshal(c.Resources) for _, raw := range spiegelRuns { @@ -450,7 +456,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") { t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held) } - spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"]) + spiegelKey, _ := engcomp.Key("n1", "de-spiegel", fps["de-spiegel"]) if _, ok := got[spiegelKey]; ok { t.Errorf("held de-spiegel's own composition is handed") } @@ -481,11 +487,11 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { rec.OutwardLinks = []string{"eth0"} b.records["n1"] = rec fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")} - siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ + siteRuns := engcomp.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`), json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}} - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) - nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"]) + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps) + nftKey, _ := engcomp.Key("n1", "nft", fps["nft"]) if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" { t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held) } @@ -521,8 +527,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { b2.records["n1"] = rec b2.held = true fps["app"] = fp("9") - got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) - appKey, _ := delivery.ComposedKey("n1", "app", fps["app"]) + got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps) + appKey, _ := engcomp.Key("n1", "app", fps["app"]) if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed { t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held) } @@ -560,7 +566,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { siteRuns.FP = fp("7") b.manifests[delivery.ManifestDigest(site)] = site b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)} - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps) if err != nil || held["site"] == "" || held["nft"] != "" { t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held) } @@ -597,7 +603,7 @@ func otherNodeNotResolvedIsNamed(t *testing.T) { given[n] = &p } a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1) - if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { + if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { t.Errorf("a refusal for want of n2's provider = %+v", a) } } diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index e793a40..4e6cb09 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -35,6 +35,7 @@ import ( "encoding/json" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "net/netip" "slices" @@ -146,7 +147,7 @@ type Scope struct { KeptAt map[string]string Placements map[string]*delivery.Placement InOwn []string - Running []delivery.Composed + Running []engcomp.Composed // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). @@ -165,7 +166,7 @@ type Scope struct { // controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module // run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own // composition keeps them as it runs them until a want names a build of the new form for one. -func InOwn(running []delivery.Composed, p delivery.Placement) []string { +func InOwn(running []engcomp.Composed, p delivery.Placement) []string { placed := map[string]bool{} for _, m := range p.Modules { placed[m.Module] = true @@ -554,7 +555,7 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte // why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a // node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served // elsewhere, a stand-in for the node's own) refuses the node, err. -func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) { +func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]engcomp.Composed, skipped []string, held map[string]string, err error) { held = map[string]string{} // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774, // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid). @@ -608,7 +609,7 @@ func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[s // ComposeSent composes node from an input ComposingInput read, through the path Compose takes (composeEach, the read's holds // first): what a check's sandbox runs. False when the input says no way of composing node (an input from a node-engine // before ComposedHow): the caller cannot compose it as the node does. -func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, bool, error) { +func ComposeSent(in compose.Input, node string) (map[string]engcomp.Composed, bool, error) { c := in.ComposedHow if c == nil || c.Node != node { return nil, false, nil @@ -763,7 +764,7 @@ func parser(Mesh) func([]byte) (catalogue.Manifest, error) { // the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build // it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to // others is taken of it, which needs no take-in of its artifacts. -func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) { +func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) { parse := parser(mesh) return func(module string) (catalogue.Manifest, bool) { for _, c := range running { @@ -1168,7 +1169,7 @@ func claimsStore(m catalogue.Manifest) bool { } // keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil. -func keptOf(c delivery.Composed) map[string]any { +func keptOf(c engcomp.Composed) map[string]any { for _, raw := range c.Resources { var r map[string]any if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() { @@ -1180,7 +1181,7 @@ func keptOf(c delivery.Composed) map[string]any { // membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own // composition, or "". -func membershipOf(own delivery.Composed) string { +func membershipOf(own engcomp.Composed) string { for _, raw := range own.Resources { var r struct { ID string `json:"id"` @@ -1244,7 +1245,7 @@ func withoutHeldData(in compose.Input, node string) compose.Input { // runningProvisioner names, on node, the account its provisioner client runs as now (compose.Node.RunningProvisioner): the // account its own composition as it runs names, else the user of the running process of the module that runs the client. -func runningProvisioner(in *compose.Input, node string, running []delivery.Composed) { +func runningProvisioner(in *compose.Input, node string, running []engcomp.Composed) { account := "" for _, c := range running { if c.Module == "" && c.Own.Provisioner != "" { diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 0ead30e..78f1a46 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -282,7 +283,7 @@ func TestInput(t *testing.T) { // Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one // thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves // here. Placed puts the same placement over the store's input unchanged. - running := make([]delivery.Composed, 0, len(fromBus)) + running := make([]compositions.Composed, 0, len(fromBus)) for _, c := range fromBus { running = append(running, c) } @@ -346,7 +347,7 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - var runsNow []delivery.Composed + var runsNow []compositions.Composed for _, c := range runs { runsNow = append(runsNow, c) } @@ -357,12 +358,12 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - var consumerRuns []delivery.Composed + var consumerRuns []compositions.Composed for _, c := range objectsRun { consumerRuns = append(consumerRuns, c) } - withoutOwn := func(cs []delivery.Composed) []delivery.Composed { - var out []delivery.Composed + withoutOwn := func(cs []compositions.Composed) []compositions.Composed { + var out []compositions.Composed for _, c := range cs { if c.Module != "" { out = append(out, c) @@ -384,7 +385,7 @@ func TestInput(t *testing.T) { p := tunnelled().placements["n1"] return map[string]*delivery.Placement{"n1": &p} } - folded := func() []delivery.Composed { + folded := func() []compositions.Composed { b := tunnelled() in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) if err != nil { @@ -417,11 +418,11 @@ func TestInput(t *testing.T) { raw, _ := json.Marshal(r) resources = append(resources, raw) } - split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{}) + split, err := compositions.Split("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, compositions.NodeOwn{}) if err != nil { t.Fatal(err) } - var out []delivery.Composed + var out []compositions.Composed for _, c := range split { out = append(out, c) } @@ -437,20 +438,20 @@ func TestInput(t *testing.T) { for name, c := range map[string]struct { b *bus given map[string]*delivery.Placement - running []delivery.Composed + running []compositions.Composed verdict string - check func(delivery.CandidateAnswer) bool + check func(nodeengine.CandidateAnswer) bool }{ - "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame, - func(a delivery.CandidateAnswer) bool { + "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, nodeengine.CandidateAnswerVerdictSame, + func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 }}, - "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame, - func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, - "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers, - func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }}, + "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), nodeengine.CandidateAnswerVerdictSame, + func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, + "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, nodeengine.CandidateAnswerVerdictDiffers, + func(a nodeengine.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }}, "one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool { + nil, nodeengine.CandidateAnswerVerdictDiffers, func(a nodeengine.CandidateAnswer) bool { for _, m := range a.Added { if m.Module == "keeper" { return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0 @@ -459,27 +460,27 @@ func TestInput(t *testing.T) { return false }}, "a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { + runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool { return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password") }}, // Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an // ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued. - "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers, - func(a delivery.CandidateAnswer) bool { + "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, nodeengine.CandidateAnswerVerdictDiffers, + func(a nodeengine.CandidateAnswer) bool { return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0 }}, // And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition. "the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { + runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool { return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 }}, - "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused, - func(a delivery.CandidateAnswer) bool { return a.Refused != "" }}, + "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused, + func(a nodeengine.CandidateAnswer) bool { return a.Refused != "" }}, // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want // names and the controller folds into the node's own, are folded there again: the node's own is the same, and // neither is added. "mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(), - folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, + folded, nodeengine.CandidateAnswerVerdictSame, func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, } { fps := oneFps if name == "mesh-wireguard and an old-form module, folded" { @@ -489,7 +490,11 @@ func TestInput(t *testing.T) { if a.Verdict != c.verdict || !c.check(a) { t.Errorf("%s: %+v", name, a) } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { + var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves + if wire, err := json.Marshal(a); err != nil || json.Unmarshal(wire, &wireAnswer) != nil { + t.Fatalf("%s: the answer does not read back: %v", name, err) + } + if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": wireAnswer}}); err != nil { t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) } } @@ -658,7 +663,7 @@ func TestInput(t *testing.T) { if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) { t.Errorf("the templates archive was read at %v", builtBus.fetched) } - resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"]) + resticKey, _ := compositions.Key("n1", "restic", movedFps["restic"]) if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled) } @@ -875,7 +880,7 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { // composed as what runs shows, said, by what runs and never by a name; a manifest that says it, or nothing running so, marks // nothing. func whatRunsIsKeptWhileAManifestDoesNotSayIt(t *testing.T) { - running := []delivery.Composed{ + running := []compositions.Composed{ {Node: "a", Module: "runner-x", Resources: []json.RawMessage{json.RawMessage(`{"id":"runner-x.runtime","type":"process"}`)}}, {Node: "a", Module: "prov-y", Resources: []json.RawMessage{json.RawMessage(`{"id":"prov-y.need-broker","type":"file","vault-sealed":{"box":"b"}}`)}}, {Node: "a", Module: "app", Resources: []json.RawMessage{json.RawMessage(`{"id":"app.need-broker","type":"file"}`)}}, diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index b6b8cdf..9598824 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -3,6 +3,7 @@ package frombustest import ( "context" "encoding/json" + engcomp "github.com/novox/mesh-host/internal/compositions" "reflect" "slices" "strings" @@ -105,7 +106,7 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { if err != nil { t.Fatalf("%s: the node's composition: %v", row.name, err) } - if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" { + if key, _ := engcomp.Key(Node, "restic", fps["restic"]); out[key].Module != "restic" { t.Errorf("%s: restic is not composed (held %v)", row.name, held) } if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) { @@ -214,14 +215,14 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { t.Fatal(err) } fps, ownKey := FPs(), "" - ownKey, _ = delivery.ComposedNodeKey(Node) - resticKey, _ := delivery.ComposedKey(Node, "restic", fps["restic"]) + ownKey, _ = engcomp.NodeKey(Node) + resticKey, _ := engcomp.Key(Node, "restic", fps["restic"]) before, _, _, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps) account := before[ownKey].Own.Provisioner if err != nil || account == "" || len(before[resticKey].Filled) == 0 { t.Fatalf("the node composes with its client: account %q, restic's filled files %v: %v", account, before[resticKey].Filled, err) } - var running []delivery.Composed + var running []engcomp.Composed for _, c := range before { running = append(running, c) } diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index 46cd176..0aa2fe2 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "testing" @@ -122,7 +123,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) - running := []delivery.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{ + running := []engcomp.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{ json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` + dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}} @@ -156,14 +157,14 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { t.Errorf("%s: the node is refused: %v", row.name, err) continue } - if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" { + if lampKey, _ := engcomp.Key("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" { t.Errorf("%s: lamp is not composed (held %v)", row.name, held) } if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) { t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held) } if row.digest != "" { - key, _ := delivery.ComposedKey("n1", "node-tools", row.want) + key, _ := engcomp.Key("n1", "node-tools", row.want) if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) { t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest) } @@ -267,7 +268,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { // A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out // of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left // out of its stand-in, never its contributions, so the provider's received file names every consumer. - storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"]) + storeKey, _ := engcomp.Key("n1", "artifacts", fps["artifacts"]) // And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is // not issued is composed without that contribution, never with "foreseen: made by the next send". for key, c := range out { @@ -284,7 +285,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { } } for _, m := range []string{"node-tools", "check", "lamp", "heal"} { - key, _ := delivery.ComposedKey("n1", m, fps[m]) + key, _ := engcomp.Key("n1", m, fps[m]) _, composed := out[key] why, isHeld := held[m] want, wantHeld := row.held[m] diff --git a/internal/compose/frombus/keep.go b/internal/compose/frombus/keep.go index 08f11fa..8f70c60 100644 --- a/internal/compose/frombus/keep.go +++ b/internal/compose/frombus/keep.go @@ -3,13 +3,13 @@ package frombus import ( "encoding/json" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "slices" "sort" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" ) @@ -25,7 +25,7 @@ import ( // keepWhatRuns marks, in in, each module placed on node that what runs shows is the tool runner or carries the core's // credentials, where its manifest does not say so; it returns, sorted by module, what it marked, in words. The tool runner is // marked only where no placed module's manifest says it is one. -func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) []KeptAsRuns { +func keepWhatRuns(in *compose.Input, node string, running []engcomp.Composed) []KeptAsRuns { placed := assignedOn(*in, node) if len(placed) == 0 || len(running) == 0 { return nil diff --git a/internal/compose/input.go b/internal/compose/input.go index 826e1e8..c33657e 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -19,11 +19,11 @@ import ( "encoding/json" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named @@ -224,7 +224,7 @@ type Node struct { // there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds, // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before. - RunsOwn *delivery.Composed + RunsOwn *engcomp.Composed // RunningProvisioner is the account the provisioner's client runs as on this node now, read from what runs (its own // composition's provisioner account, else the user of the provisioner client's running process), empty when nothing // says it. A client held as its stub this pass composes no process, so the node keeps this account and its handing @@ -254,7 +254,7 @@ type ComposedHow struct { // Held is each module the read held, with why: composed as what runs of it, and the rest without it. Held map[string]string // Running is what runs of each held module, its own composition, by which it is carried as it runs. - Running []delivery.Composed + Running []engcomp.Composed // HeldBefore is each module the node holds composing without the change (the candidate's base), held by the read or // blamed later for a refusal: the change refuses none of them (issue 774, review round 1). HeldBefore map[string]string diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 18c5309..a2a2101 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -3,6 +3,7 @@ package composer import ( "context" "encoding/json" + engcomp "github.com/novox/mesh-host/internal/compositions" "os" "reflect" "strings" @@ -122,7 +123,7 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { t.Fatal(err) } node := compose.Node{Name: "a", Assigned: []string{"bus"}, InOwn: map[string]bool{"bus": true}, - RunsOwn: &delivery.Composed{Node: "a"}} + RunsOwn: &engcomp.Composed{Node: "a"}} in := compose.Input{Catalogue: map[string]catalogue.Manifest{"bus": man}, Nodes: []compose.Node{node}} request := func(in compose.Input) Request { return withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, diff --git a/internal/compositions/answer_paths.go b/internal/compositions/answer_paths.go index a3217a4..001ed30 100644 --- a/internal/compositions/answer_paths.go +++ b/internal/compositions/answer_paths.go @@ -3,7 +3,6 @@ package compositions import ( "bytes" "encoding/json" - "git.novox.be/novox/mesh-sdk/go/delivery" "sort" "strconv" "strings" @@ -31,9 +30,9 @@ func DifferingPaths(composed, running json.RawMessage) []string { var out []string walkDiffering(a, b, "", &out) sort.Strings(out) - if len(out) > delivery.MaxPaths { - more := len(out) - (delivery.MaxPaths - 1) - out = append(out[:delivery.MaxPaths-1], "… "+strconv.Itoa(more)+" more") + if len(out) > MaxPaths { + more := len(out) - (MaxPaths - 1) + out = append(out[:MaxPaths-1], "… "+strconv.Itoa(more)+" more") } return out } diff --git a/internal/compositions/candidate.go b/internal/compositions/candidate.go new file mode 100644 index 0000000..0bed69d --- /dev/null +++ b/internal/compositions/candidate.go @@ -0,0 +1,53 @@ +package compositions + +import ( + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" +) + +// The bounds of a module's shown bodies in a candidate answer, and of one answer as the node's key holds it (the node-engine +// seat's state have, key `.node`, takes a value of about 1 MiB and holds every proposal's answer beside the node's +// facts, so one answer keeps to a sixth). +const ( + MaxBodies = 20 + MaxBody = 8192 + MaxPaths = 64 + MaxAnswerBytes = 160 << 10 +) + +// ReturnsToNothing is what a node answers a proposal that takes its placement key away. +const ReturnsToNothing = "the proposal takes this node's placement away: it composes nothing then" + +// NodeWord is the last part of a node's own key, and the name of its own composition in an answer. +const NodeWord = "node" + +// VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or +// removal, same otherwise. +func VerdictOf(a seat.CandidateAnswer) string { + switch { + case a.Refused != "" || len(a.Refusals) > 0: + return seat.CandidateAnswerVerdictRefused + case len(a.Differs)+len(a.Added)+len(a.Removed) > 0: + return seat.CandidateAnswerVerdictDiffers + } + return seat.CandidateAnswerVerdictSame +} + +// WithValuesDiffer is answers with each one's ValuesDiffer counted: how many shown bodies hold a value the node opened on +// both sides to two values (the vault's value and the one that runs drifted apart; issue 1126, cause C4). +func WithValuesDiffer(answers map[string]seat.CandidateAnswer) map[string]seat.CandidateAnswer { + out := make(map[string]seat.CandidateAnswer, len(answers)) + for g, a := range answers { + var shown []meshvaultclient.ModuleBodies + for _, d := range a.Differs { + m := meshvaultclient.ModuleBodies{Module: d.Module} + for _, b := range d.Bodies { + m.Bodies = append(m.Bodies, meshvaultclient.Body{ID: b.ID, Composed: b.Composed, Running: b.Running}) + } + shown = append(shown, m) + } + a.ValuesDiffer = int64(meshvaultclient.ValuesDiffer(shown)) + out[g] = a + } + return out +} diff --git a/internal/compositions/compare_test.go b/internal/compositions/compare_test.go index fa5bc42..8a3c1e5 100644 --- a/internal/compositions/compare_test.go +++ b/internal/compositions/compare_test.go @@ -4,11 +4,10 @@ import ( "bytes" "encoding/json" "fmt" + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "slices" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // What the node-engine finds when it compares the compositions it made with the ones it runs (issue 1126): the bus's user @@ -21,11 +20,11 @@ func TestComparingCompositions(t *testing.T) { return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`) } fp := "src2:" + strings.Repeat("a", 64) - comp := func(rs ...json.RawMessage) delivery.Composed { - return delivery.Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs} + comp := func(rs ...json.RawMessage) Composed { + return Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs} } for name, tc := range map[string]struct { - composed, running delivery.Composed + composed, running Composed resource, why string }{ "user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""}, @@ -33,7 +32,7 @@ func TestComparingCompositions(t *testing.T) { "one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"}, "order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"}, } { - c := CompareComposed(map[string]delivery.Composed{"novox.nats." + fp: tc.composed}, []delivery.Composed{tc.running}) + c := CompareComposed(map[string]Composed{"novox.nats." + fp: tc.composed}, []Composed{tc.running}) if tc.why == "" { if len(c.Differs) != 0 || len(c.Same) != 1 { t.Errorf("%s: %+v", name, c) @@ -54,15 +53,15 @@ func TestComparingCompositions(t *testing.T) { sameBox := func(a, b string) (same, known bool) { return (a == "box-2" && b == "box-3") || a == b, true } - shown := CompareComposedWith(map[string]delivery.Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), + shown := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), file("c", "x"), sealedFile("d", "box-2", "0600"))}, - []delivery.Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"), sealedFile("d", "box-4", "0600"))}, + []Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"), sealedFile("d", "box-4", "0600"))}, sameBox) if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 { t.Fatalf("every differing resource is not shown, or b sealed again is shown: %+v", shown.Differs) } - if c := CompareComposedWith(map[string]delivery.Composed{"k": comp(sealedFile("b", "box-2", "0600"))}, - []delivery.Composed{comp(sealedFile("b", "box-3", "0600"))}, sameBox); len(c.Same) != 1 { + if c := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("b", "box-2", "0600"))}, + []Composed{comp(sealedFile("b", "box-3", "0600"))}, sameBox); len(c.Same) != 1 { t.Errorf("a value sealed again at its own slot is not the same: %+v", c) } for _, row := range []struct { @@ -73,7 +72,7 @@ func TestComparingCompositions(t *testing.T) { {"c", `"content":"x"`, "", []string{"content"}}, {"d", "", "box-2", []string{"sealed"}}, } { - var body delivery.ResourceBodies + var body seat.CandidateBodies for _, b := range shown.Differs[0].Bodies { if b.ID == row.id { body = b @@ -93,8 +92,8 @@ func TestComparingCompositions(t *testing.T) { `"id":"grafana.ini","mode":"0600","module":"nats","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":"` + db + `","secret-key":"` + key + `"},"type":"file"}`) } - if c := CompareComposedWith(map[string]delivery.Composed{"k": comp(withSecrets("box-2", "box-1"))}, - []delivery.Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 { + if c := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-1"))}, + []Composed{comp(withSecrets("box-3", "box-1"))}, sameBox); len(c.Same) != 1 { t.Errorf("a secret sealed again at its own entry is not the same: %+v", c) } if kept := KeepSeals(comp(withSecrets("box-2", "box-1")), SealsIn(comp(withSecrets("box-3", "box-1"))), sameBox); !bytes.Equal(kept.Resources[0], withSecrets("box-3", "box-1")) { @@ -105,8 +104,8 @@ func TestComparingCompositions(t *testing.T) { } // Another value at one entry differs there alone, each entry masked by what it opens to. // box-2 and box-4 open to one value, so the box that runs is kept there; box-5 and box-6 are two values. - mixed := CompareComposedWith(map[string]delivery.Composed{"k": comp(withSecrets("box-2", "box-5"))}, - []delivery.Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) { + mixed := CompareComposedWith(map[string]Composed{"k": comp(withSecrets("box-2", "box-5"))}, + []Composed{comp(withSecrets("box-4", "box-6"))}, func(a, b string) (bool, bool) { return (a == "box-2" && b == "box-4") || a == b, true }) if len(mixed.Differs) != 1 || len(mixed.Differs[0].Bodies) != 1 { @@ -128,7 +127,7 @@ func TestComparingCompositions(t *testing.T) { return json.RawMessage(`{"id":"core-box","type":"file","module":"nats","path":"/etc/core","mode":"0600","vault-sealed":{"v":1,"eph":"e-` + ct + `","ct":"` + ct + `"}}`) } - vault := CompareComposedWith(map[string]delivery.Composed{"k": comp(box("c1"))}, []delivery.Composed{comp(box("c2"))}, + vault := CompareComposedWith(map[string]Composed{"k": comp(box("c1"))}, []Composed{comp(box("c2"))}, func(a, b string) (bool, bool) { return false, strings.Contains(a, `"ct":"c1"`) && strings.Contains(b, `"ct":"c2"`) }) @@ -141,14 +140,14 @@ func TestComparingCompositions(t *testing.T) { } // Issue 1126, T1: the paths are found on the whole bodies, before they are cut; JSON in a string is walked into, a // key a secret could be under is named and never shown. - long := strings.Repeat("x", delivery.MaxBody) + long := strings.Repeat("x", MaxBody) conf := func(link, password string) json.RawMessage { inner, _ := json.Marshal(map[string]any{"pad": long, "links": []string{"a", link}, "password": password}) outer, _ := json.Marshal(map[string]any{"id": "conf", "type": "file", "module": "nats", "path": "/etc/conf", "mode": "0600", "content": string(inner)}) return outer } - far := CompareComposedWith(map[string]delivery.Composed{"k": comp(conf("b", "hunter2"))}, []delivery.Composed{comp(conf("c", "hunter3"))}, nil) + far := CompareComposedWith(map[string]Composed{"k": comp(conf("b", "hunter2"))}, []Composed{comp(conf("c", "hunter3"))}, nil) if len(far.Differs) != 1 || len(far.Differs[0].Bodies) != 1 || !slices.Equal(far.Differs[0].Bodies[0].Paths, []string{"content.links.1", "content.password"}) || !strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") { @@ -160,10 +159,10 @@ func TestComparingCompositions(t *testing.T) { !strings.Contains(c, "") { t.Errorf("masked as %s and %s", c, r) } - if c := CompareComposed(nil, []delivery.Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" { + if c := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" { t.Errorf("the node's own composition run and not composed is removed: %+v", c) } - if c := CompareComposed(map[string]delivery.Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" { + if c := CompareComposed(map[string]Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" { t.Errorf("a composition not run is added: %+v", c) } } diff --git a/internal/compositions/composed.go b/internal/compositions/composed.go new file mode 100644 index 0000000..8107422 --- /dev/null +++ b/internal/compositions/composed.go @@ -0,0 +1,251 @@ +package compositions + +import ( + "encoding/json" + "fmt" + "regexp" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +var bucketNode = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// NodeOwn is the node's own parts in a composition (the node-engine seat's state composed, key `.node`): its layout +// is the seat's contract (nodeengine.NodeOwn); the engine holds them as the declaration package's types. +type NodeOwn struct { + Adoption *declaration.Adoption `json:"adoption,omitempty"` + Identities []declaration.Identity `json:"identities,omitempty"` + Groups []declaration.Group `json:"groups,omitempty"` + Libraries []declaration.Library `json:"libraries,omitempty"` + Provisioner string `json:"provisioner_account,omitempty"` +} + +// Composed is a composition (the node-engine seat's state composed): one module's resources composed for one node at one +// fingerprint, or, with Module and FP empty, the node's own. Its codec, digest and keys are the node-engine client's +// (nodeengineclient); the engine holds the plan as the catalogue's Plan, which the client carries as JSON. +type Composed struct { + V int + Node string + Module string + FP string + Data bool + Resources []json.RawMessage + Filled []string + Own NodeOwn + Plan *catalogue.Plan + Refused string +} + +// Bucket constants of a node's compositions (the state composed is one bucket per node). +const ( + // BucketPrefix starts the name of a node's bucket of compositions, BucketFor(node). + BucketPrefix = "composed-" + // History is the revisions a node's composition bucket keeps per key. + History = 5 + // Keep is how many of a module's newest fingerprints keep their compositions though no want names them. + Keep = 5 +) + +// BucketFor is the name of a node's bucket of compositions, "composed-". +func BucketFor(node string) (string, error) { + if node == "" { + return "", fmt.Errorf("compositions: the node name is empty; a node's composition bucket is named for its node") + } + if !bucketNode.MatchString(node) { + return "", fmt.Errorf("compositions: the node name %q is not one a bucket accepts: letters, digits, '_' and '-' only", node) + } + return BucketPrefix + node, nil +} + +func toClient(c Composed) (nodeengineclient.Composition, error) { + out := nodeengineclient.Composition{V: c.V, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, + Resources: c.Resources, Filled: c.Filled, Refused: c.Refused} + raw, err := json.Marshal(c.Own) + if err != nil { + return out, err + } + if err := json.Unmarshal(raw, &out.Own); err != nil { + return out, fmt.Errorf("compositions: the node's own parts do not fit the seat's layout: %w", err) + } + if c.Plan != nil { + if out.Plan, err = json.Marshal(c.Plan); err != nil { + return out, err + } + } + return out, nil +} + +func fromClient(c nodeengineclient.Composition) (Composed, error) { + out := Composed{V: c.V, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Resources: c.Resources, + Filled: c.Filled, Refused: c.Refused} + raw, err := json.Marshal(c.Own) + if err != nil { + return out, err + } + if err := json.Unmarshal(raw, &out.Own); err != nil { + return out, fmt.Errorf("compositions: the node's own parts do not fit the engine's: %w", err) + } + if len(c.Plan) > 0 { + out.Plan = &catalogue.Plan{} + if err := json.Unmarshal(c.Plan, out.Plan); err != nil { + return out, fmt.Errorf("compositions: the plan does not fit: %w", err) + } + } + return out, nil +} + +// Encode is c as the client writes it. +func Encode(c Composed) ([]byte, error) { + cc, err := toClient(c) + if err != nil { + return nil, err + } + return nodeengineclient.EncodeComposition(cc) +} + +// Decode reads a composition as the client reads it. +func Decode(data []byte) (Composed, error) { + cc, err := nodeengineclient.DecodeComposition(data) + if err != nil { + return Composed{}, err + } + return fromClient(cc) +} + +// DigestOf is a composition's digest, "sha256:", of its encoding without its plan; a refused one has none. +func DigestOf(c Composed) (string, error) { + cc, err := toClient(c) + if err != nil { + return "", err + } + return nodeengineclient.ComposedDigest(cc) +} + +// Same says whether two compositions are the same value, their plans included. +func Same(a, b Composed) bool { + ca, errA := toClient(a) + cb, errB := toClient(b) + return errA == nil && errB == nil && nodeengineclient.SameComposition(ca, cb) +} + +// Key is the key of a module's composition for a node at a fingerprint: `..`. +func Key(node, module, fingerprint string) (string, error) { + part, err := meshcatalogclient.KeyPart(fingerprint) + if err != nil { + return "", err + } + return nodeengineclient.ComposedKey(node, module, part) +} + +// NodeKey is the key of a node's own composition: `.node`. +func NodeKey(node string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + return node + "." + nodeengineclient.NodeWord, nil +} + +// ParseKey is the inverse of Key and NodeKey: isNode is true for `.node`, with module and fingerprint empty. +func ParseKey(key string) (node, module, fingerprint string, isNode bool, err error) { + node, module, part, isNode, err := nodeengineclient.ParseComposedKey(key) + if err != nil || isNode { + return node, module, "", isNode, err + } + return node, module, meshcatalogclient.SourcePrefix2 + part, false, nil +} + +// ByDigest is c with every file over 64 KiB given by its digest and size, and the content of each by digest, for the +// writer to put in the node's object bucket (declaration.FileBucketFor) under declaration.ObjectName before it writes +// the composition. A file that is sealed, filled with secrets or written into another stays as it is. +func (c Composed) ByDigest() (Composed, map[string][]byte, error) { + out := c + out.Resources = make([]json.RawMessage, len(c.Resources)) + objects := map[string][]byte{} + for i, raw := range c.Resources { + out.Resources[i] = raw + r, err := declaration.ParseResource(raw) + if err != nil { + return Composed{}, nil, err + } + f, ok := r.(*declaration.File) + if !ok || !f.TravelsByDigest() { + continue + } + ref, body, err := f.AsReference() + if err != nil { + return Composed{}, nil, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return Composed{}, nil, err + } + delete(fields, "content") + delete(fields, "bytes") + fields["digest"], _ = json.Marshal(ref.Digest) + fields["size"], _ = json.Marshal(ref.Size) + rewritten, err := json.Marshal(fields) + if err != nil { + return Composed{}, nil, err + } + out.Resources[i] = rewritten + objects[ref.Digest] = body + } + return out, objects, nil +} + +// Split splits a node's composed resources into its compositions, by key (Key, NodeKey): each resource into the +// composition of the module it says, under that module's fingerprint in fps, and a resource that says no module into the +// node's own, with own. Each filled path goes with the module whose file declares it. The order of the resources is kept +// within each composition. A resource of a module fps names no fingerprint for is refused, naming it. +func Split(node string, resources []json.RawMessage, fps map[string]string, filled []string, own NodeOwn) (map[string]Composed, error) { + nodeKey, err := NodeKey(node) + if err != nil { + return nil, err + } + out := map[string]Composed{nodeKey: {Node: node, Own: own, Resources: []json.RawMessage{}}} + pathOf := map[string]string{} + for _, raw := range resources { + var head struct { + ID string `json:"id"` + Type string `json:"type"` + Module string `json:"module"` + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &head); err != nil { + return nil, fmt.Errorf("compositions: a resource of %s is not JSON: %w", node, err) + } + key := nodeKey + if head.Module != "" { + fp, ok := fps[head.Module] + if !ok { + return nil, fmt.Errorf("compositions: the resource %q is of %q, whose fingerprint on %s is not given", head.ID, head.Module, node) + } + if key, err = Key(node, head.Module, fp); err != nil { + return nil, err + } + if _, ok := out[key]; !ok { + out[key] = Composed{Node: node, Module: head.Module, FP: fp} + } + } + if head.Type == string(declaration.TypeFile) && head.Module != "" { + pathOf[head.Path] = key + } + c := out[key] + c.Resources = append(c.Resources, raw) + out[key] = c + } + for _, path := range filled { + key, ok := pathOf[path] + if !ok { + return nil, fmt.Errorf("compositions: the provisioner fills %q on %s, and no module's file declares it", path, node) + } + c := out[key] + c.Filled = append(c.Filled, path) + out[key] = c + } + return out, nil +} diff --git a/internal/compositions/composed_compare.go b/internal/compositions/composed_compare.go index fc244e0..446021b 100644 --- a/internal/compositions/composed_compare.go +++ b/internal/compositions/composed_compare.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "errors" - "git.novox.be/novox/mesh-sdk/go/delivery" + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "io" "maps" "slices" @@ -18,8 +18,8 @@ import ( // now) and removed by it (run now, not composed). Each list is sorted by module. type Comparison struct { Same []string - Differs []delivery.ModuleDiffers - Added []delivery.ModuleAdded + Differs []seat.CandidateDiffers + Added []seat.CandidateAdded Removed []string } @@ -32,25 +32,25 @@ type Comparison struct { // difference names the first resource, by its id, whose content differs or that one side alone holds; when every // resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file // carried by digest). -func CompareComposed(composed map[string]delivery.Composed, running []delivery.Composed) Comparison { +func CompareComposed(composed map[string]Composed, running []Composed) Comparison { return CompareComposedWith(composed, running, nil) } // CompareComposedWith is CompareComposed showing, for each module that differs, every differing resource with both // bodies masked (MaskedPair); sameValue, when given, says whether two sealed values open to the same value (the node // opens them with its own key), so a value sealed again reads as such. -func CompareComposedWith(composed map[string]delivery.Composed, running []delivery.Composed, sameValue func(a, b string) (same, known bool)) Comparison { - name := func(c delivery.Composed) string { +func CompareComposedWith(composed map[string]Composed, running []Composed, sameValue func(a, b string) (same, known bool)) Comparison { + name := func(c Composed) string { if c.Module == "" { - return delivery.HaveNodeWord + return NodeWord } return c.Module } - ours := map[string]delivery.Composed{} + ours := map[string]Composed{} for _, c := range composed { ours[name(c)] = c } - theirs := map[string]delivery.Composed{} + theirs := map[string]Composed{} for _, c := range running { theirs[name(c)] = c } @@ -67,8 +67,8 @@ func CompareComposedWith(composed map[string]delivery.Composed, running []delive b, runs := theirs[m] switch { case !runs: - dig, _ := delivery.ComposedDigestOf(withoutBusUsers(a)) - out.Added = append(out.Added, delivery.ModuleAdded{Module: m, Composed: dig}) + dig, _ := DigestOf(withoutBusUsers(a)) + out.Added = append(out.Added, seat.CandidateAdded{Module: m, Composed: dig}) case !composedHere: out.Removed = append(out.Removed, m) default: @@ -83,21 +83,21 @@ func CompareComposedWith(composed map[string]delivery.Composed, running []delive } // compareOne is one module's comparison: true when the digests agree, else what differs first. -func compareOne(module string, composed, running delivery.Composed, sameValue func(a, b string) (bool, bool)) (delivery.ModuleDiffers, bool) { +func compareOne(module string, composed, running Composed, sameValue func(a, b string) (bool, bool)) (seat.CandidateDiffers, bool) { composed, running = withoutBusUsers(composed), withoutBusUsers(running) // A sealed value is judged by the value it opens to, slot by slot (issue 1126, C1): a value sealed again at the same // field of the same resource is the box that runs, never a difference; and a box is never matched across resources. composed = KeepSeals(composed, SealsIn(running), sameValue) - dc, errC := delivery.ComposedDigestOf(composed) - dr, errR := delivery.ComposedDigestOf(running) + dc, errC := DigestOf(composed) + dr, errR := DigestOf(running) if errC != nil { - return delivery.ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false + return seat.CandidateDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false } if errR == nil && dc == dr { - return delivery.ModuleDiffers{}, true + return seat.CandidateDiffers{}, true } - differs := func(resource, why string) (delivery.ModuleDiffers, bool) { - return delivery.ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false + differs := func(resource, why string) (seat.CandidateDiffers, bool) { + return seat.CandidateDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false } // Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole // and the same file carried by digest read as one. @@ -114,9 +114,9 @@ func compareOne(module string, composed, running delivery.Composed, sameValue fu for id := range rb { ids[id] = true } - var first delivery.ModuleDiffers + var first seat.CandidateDiffers found := false - var bodies []delivery.ResourceBodies + var bodies []seat.CandidateBodies for _, id := range slices.Sorted(maps.Keys(ids)) { x, inA := ra[id] y, inB := rb[id] @@ -135,11 +135,11 @@ func compareOne(module string, composed, running delivery.Composed, sameValue fu first, _ = differs(id, why) found = true } - if len(bodies) < delivery.MaxBodies { + if len(bodies) < MaxBodies { // The paths are found on the whole bodies, before either is cut (issue 1126, T1: three resources over // MaxBody could not be told apart). c, r := MaskedPair(x, y, sameValue) - bodies = append(bodies, delivery.ResourceBodies{ID: id, Why: why, Paths: DifferingPaths(x, y), + bodies = append(bodies, seat.CandidateBodies{ID: id, Why: why, Paths: DifferingPaths(x, y), Composed: cut(c), Running: cut(r)}) } } @@ -157,8 +157,8 @@ func compareOne(module string, composed, running delivery.Composed, sameValue fu case a.FP != b.FP: return differs("", "the fingerprint differs") } - if dA, err := delivery.ComposedDigestOf(a); err == nil { - if dB, err := delivery.ComposedDigestOf(b); err == nil && dA == dB { + if dA, err := DigestOf(a); err == nil { + if dB, err := DigestOf(b); err == nil && dA == dB { return differs("", "the same content, a large file carried whole on one side and by digest on the other") } } @@ -166,7 +166,7 @@ func compareOne(module string, composed, running delivery.Composed, sameValue fu } // withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `.bus-users`). -func withoutBusUsers(c delivery.Composed) delivery.Composed { +func withoutBusUsers(c Composed) Composed { out := c out.Resources = nil for _, raw := range c.Resources { @@ -204,10 +204,10 @@ func resourcesByID(resources []json.RawMessage) map[string][]byte { // cut is s cut at MaxBody bytes, saying so. func cut(s string) string { - if len(s) <= delivery.MaxBody { + if len(s) <= MaxBody { return s } - return s[:delivery.MaxBody] + "… (cut at " + strconv.Itoa(delivery.MaxBody) + " bytes)" + return s[:MaxBody] + "… (cut at " + strconv.Itoa(MaxBody) + " bytes)" } // secretWords are the words of a key whose value may hold a secret or a sealed one: masked in a shown body. diff --git a/internal/compositions/composed_seals.go b/internal/compositions/composed_seals.go index a3374da..da5e0f6 100644 --- a/internal/compositions/composed_seals.go +++ b/internal/compositions/composed_seals.go @@ -3,7 +3,6 @@ package compositions import ( "bytes" "encoding/json" - "git.novox.be/novox/mesh-sdk/go/delivery" "sort" "strconv" @@ -63,7 +62,7 @@ func sealsAt(fields map[string]json.RawMessage) []sealAt { } // SealsIn is every sealed value c carries, by slot; nil when it carries none. -func SealsIn(c delivery.Composed) Seals { +func SealsIn(c Composed) Seals { var out Seals for i, raw := range c.Resources { id, fields, ok := sealedFieldsOf(raw, i) @@ -86,7 +85,7 @@ func SealsIn(c delivery.Composed) Seals { // same value (known and same): a value the vault sealed again is then the box that runs, so the composition is the // one that runs (ADR 0547 §3, issue 1126). A slot kept holds nothing for, or whose value differs or cannot be opened, // keeps c's. Every other byte of a resource is kept as it is; c itself is not changed. -func KeepSeals(c delivery.Composed, kept Seals, same func(a, b string) (same, known bool)) delivery.Composed { +func KeepSeals(c Composed, kept Seals, same func(a, b string) (same, known bool)) Composed { if len(kept) == 0 || same == nil { return c } diff --git a/internal/compositions/packages.go b/internal/compositions/packages.go new file mode 100644 index 0000000..c371abe --- /dev/null +++ b/internal/compositions/packages.go @@ -0,0 +1,84 @@ +package compositions + +import ( + "encoding/json" + "fmt" + "sort" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// PackagesBucket is the node-engine's state packages (the seat's state of the same name), one key per node. +const PackagesBucket = "mesh-host_" + seat.StatePackages + +// DeclaredPackages is a node's key of `packages` from the compositions its engine composed for the node's wants: +// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so, +// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the +// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module +// lists none. +func DeclaredPackages(node string, comps []Composed) (seat.PackagesByNodeValue, error) { + by := map[packageKey]map[string]bool{} + for _, c := range comps { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Type string `json:"type"` + Package string `json:"package"` + Absent bool `json:"absent"` + Module string `json:"module"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return seat.PackagesByNodeValue{}, fmt.Errorf("compositions: a resource of %s's composition %q is not readable: %w", node, c.Module, err) + } + if r.Type != "package" || r.Package == "" { + continue + } + k := packageKey{r.Package, r.Absent} + if by[k] == nil { + by[k] = map[string]bool{} + } + module := r.Module + if module == "" { + module = c.Module + } + if module == "" { + module, _ = declaration.ModuleGuess(r.ID) + } + if module != "" { + by[k][module] = true + } + } + } + v := seat.PackagesByNodeValue{Node: node, Packages: []seat.PackagesByNodeValuePackagesItem{}} + for _, k := range sortedPackageKeys(by) { + modules := make([]string, 0, len(by[k])) + for m := range by[k] { + modules = append(modules, m) + } + sort.Strings(modules) + v.Packages = append(v.Packages, seat.PackagesByNodeValuePackagesItem{Package: k.name, Modules: modules, Absent: k.absent}) + } + return v, nil +} + +// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent. +type packageKey struct { + name string + absent bool +} + +// sortedPackageKeys is the keys of m by name, absent after present. +func sortedPackageKeys[V any](m map[packageKey]V) []packageKey { + keys := make([]packageKey, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].name != keys[j].name { + return keys[i].name < keys[j].name + } + return !keys[i].absent && keys[j].absent + }) + return keys +} diff --git a/internal/judge/aliases.go b/internal/judge/aliases.go index 589de07..bf0f30e 100644 --- a/internal/judge/aliases.go +++ b/internal/judge/aliases.go @@ -4,15 +4,19 @@ // the shapes it judges (a composition, a judgement's words, a candidate's answer) stay in mesh-sdk. package judge -import "git.novox.be/novox/mesh-sdk/go/delivery" +import ( + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/delivery" + engcomp "github.com/novox/mesh-host/internal/compositions" +) // The shapes of mesh-sdk's delivery package this package judges, by the names its code was written with. type ( - Composed = delivery.Composed + Composed = engcomp.Composed Judgement = delivery.Judgement - CandidateAnswer = delivery.CandidateAnswer - ModuleDiffers = delivery.ModuleDiffers - ResourceBodies = delivery.ResourceBodies + CandidateAnswer = nodeengine.CandidateAnswer + ModuleDiffers = nodeengine.CandidateDiffers + ResourceBodies = nodeengine.CandidateBodies ) const ( diff --git a/internal/judge/answer_test.go b/internal/judge/answer_test.go index 62535c6..bdd7d9f 100644 --- a/internal/judge/answer_test.go +++ b/internal/judge/answer_test.go @@ -1,6 +1,7 @@ package judge import ( + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "strings" "testing" @@ -11,7 +12,7 @@ import ( // given is not changed (moved from mesh-sdk's placement test with FitAnswer, novox/hq ADR 0593 §7). func TestFitAnswer(t *testing.T) { long := strings.Repeat("x", delivery.MaxBody) - big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: delivery.CandidateDiffers, Differs: []ModuleDiffers{{ + big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictDiffers, Differs: []ModuleDiffers{{ Module: "nats", Why: "differs", Bodies: []ResourceBodies{{ID: "conf", Why: "changed", Paths: []string{"content.links.1", "content.password"}, Composed: long, Running: long}}}}} if same := FitAnswer(big, 1<<20); same.Note != "" || same.Differs[0].Bodies[0].Composed != long { @@ -19,7 +20,7 @@ func TestFitAnswer(t *testing.T) { } fitted := FitAnswer(big, 4<<10) if len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 || - fitted.Verdict != delivery.CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" { + fitted.Verdict != nodeengine.CandidateAnswerVerdictDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" { t.Errorf("fitted as %+v", fitted) } } diff --git a/internal/link/composedstate.go b/internal/link/composedstate.go index b069208..d639720 100644 --- a/internal/link/composedstate.go +++ b/internal/link/composedstate.go @@ -4,8 +4,8 @@ import ( "context" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/selfcompose" @@ -14,9 +14,9 @@ import ( // ---- this node's composition state, in the bus's own storage (novox/hq ADR 0593 §6) -------------------------------- // composedStore binds the node's composition state `composed-` over the link open now: bound, never created (the -// grants part creates it, create-only, history delivery.ComposedHistory). +// grants part creates it, create-only, history engcomp.History). func composedStore(js nats.JetStreamContext, node string) (nats.KeyValue, error) { - bucket, err := delivery.ComposedBucketFor(node) + bucket, err := engcomp.BucketFor(node) if err != nil { return nil, err } diff --git a/internal/link/enginepackages.go b/internal/link/enginepackages.go index 3e7d16c..6f54cc4 100644 --- a/internal/link/enginepackages.go +++ b/internal/link/enginepackages.go @@ -5,8 +5,9 @@ import ( "encoding/json" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/compositions" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" ) @@ -14,8 +15,8 @@ import ( // PackagesBucket is the node-engine's declared state packages, created by the grants part from the engine's manifest as // its states health and data are: one key, the node's name, written by that node's engine alone, read by pacman as -// mesh-host.packages (delivery.EnginePackagesRead). -const PackagesBucket = "mesh-host_" + delivery.EnginePackagesState +// mesh-host.packages ("mesh-host." + nodeengine.StatePackages). +const PackagesBucket = compositions.PackagesBucket // packagesStore binds PackagesBucket over the link open now: bound, never created. func (l *natsLink) packagesStore() (nats.KeyValue, error) { @@ -84,9 +85,9 @@ func (p EnginePackages) Put(_ context.Context, key string, value []byte) error { type PackagesView struct { Node string `json:"node"` // Key is the node's key as the bus holds it (Source); null when the engine has written none. - Source string `json:"source"` - Key *delivery.DeclaredPackages `json:"key"` - KeyError string `json:"key_error,omitempty"` + Source string `json:"source"` + Key *nodeengine.PackagesByNodeValue `json:"key"` + KeyError string `json:"key_error,omitempty"` // Wrote says, for write, whether the key was written again (false: it already held what the engine composed). Wrote *bool `json:"wrote,omitempty"` Note string `json:"note,omitempty"` @@ -94,9 +95,9 @@ type PackagesView struct { // PackagesAnswer is the verb's answer from the key as read (nil: no key), with its read's error. func PackagesAnswer(node string, key []byte, keyErr error) PackagesView { - v := PackagesView{Node: node, Source: delivery.EnginePackagesRead} + v := PackagesView{Node: node, Source: "mesh-host." + nodeengine.StatePackages} if keyErr == nil && key != nil { - var d delivery.DeclaredPackages + var d nodeengine.PackagesByNodeValue if keyErr = json.Unmarshal(key, &d); keyErr == nil { v.Key = &d } diff --git a/internal/link/have.go b/internal/link/have.go index af8ac34..656de22 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "github.com/novox/mesh-host/internal/compositions" "sort" @@ -53,7 +54,7 @@ type haveState struct { // issue 1126): kept in its key so a restart reuses them and applies nothing again. nodeSealed compositions.Seals // nodeCandidates is the node's answer to each placement candidate proposal (novox/hq ADR 0559 §4), written into its key. - nodeCandidates map[string]delivery.CandidateAnswer + nodeCandidates map[string]nodeengine.CandidateAnswer // nodeComposes and nodeComposesWhy are whose compositions the node applies and why (HaveNode.Composes), written into // its key. nodeComposes, nodeComposesWhy string @@ -433,7 +434,7 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { facts := *s.node facts.Composed = s.nodeComposed facts.SealedAt = s.nodeSealed - facts.Candidates = s.nodeCandidates + facts.Candidates = interimAnswers(s.nodeCandidates) facts.Composes, facts.ComposesWhy = s.nodeComposes, s.nodeComposesWhy if raw, err = delivery.EncodeHaveNode(facts); err == nil { want := facts @@ -743,7 +744,7 @@ func (q *Session) NoteNode(ctx context.Context, composed string, sealed composit // NoteCandidates takes the node's answers to the placement candidates, by proposal (its shadow composer's, novox/hq ADR // 0559 §4), and writes the node's key where it differs; a proposal left out is no longer answered. -func (q *Session) NoteCandidates(ctx context.Context, answers map[string]delivery.CandidateAnswer) { +func (q *Session) NoteCandidates(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) { q.init() s := &q.haves s.mu.Lock() @@ -878,3 +879,21 @@ func nothingJudged(f ModuleFact, looked bool) delivery.Health { } return delivery.HealthUnknown } + +// interimAnswers is the node's candidate answers in the form mesh-sdk's HaveNode still holds them, until the node-engine's +// have is written through the seat's contract types (the Have group of the go/delivery rewrite): the two shapes share +// their wire names, so the JSON carries one into the other. +func interimAnswers(in map[string]nodeengine.CandidateAnswer) map[string]delivery.CandidateAnswer { + if in == nil { + return nil + } + raw, err := json.Marshal(in) + if err != nil { + return nil + } + var out map[string]delivery.CandidateAnswer + if json.Unmarshal(raw, &out) != nil { + return nil + } + return out +} diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 11669f6..af02cab 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "github.com/novox/mesh-host/internal/compositions" "slices" @@ -612,7 +613,7 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( t.Fatalf("the node key read back: %+v %v", n, ok) } // The node's answers to placement candidates ride in the same key (ADR 0559 §4), and go when no group is answered. - answers := map[string]delivery.CandidateAnswer{"p-1": {Proposal: "p-1", Revision: 1, Verdict: delivery.CandidateSame, Same: []string{"web"}}} + answers := map[string]nodeengine.CandidateAnswer{"p-1": {Proposal: "p-1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"web"}}} q.NoteCandidates(context.Background(), answers) if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Candidates["p-1"].Proposal != "p-1" || n.Composed != dig { t.Fatalf("the node key with its answers: %+v %v", n, err) diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index a2c102d..aaf30a3 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "slices" "sort" @@ -173,7 +174,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi // reads every manifest in the mesh one by one, which off the control-node took about 35 s each (issue 1126). base.warm(ctx, c, cand) - var running []delivery.Composed + var running []engcomp.Composed if c.env.Running != nil { running = c.env.Running() } @@ -232,8 +233,8 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi case laidErr != nil: return refuse(laidErr.Error()) } - byModule := func(comps map[string]delivery.Composed) map[string]delivery.Composed { - out := map[string]delivery.Composed{} + byModule := func(comps map[string]engcomp.Composed) map[string]engcomp.Composed { + out := map[string]engcomp.Composed{} for _, comp := range comps { out[comp.Module] = comp } @@ -256,7 +257,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi if !ok { return m, false } - dig, err := delivery.ComposedDigestOf(comp) + dig, err := engcomp.DigestOf(comp) if err != nil { m.State, m.Words = nec.ModuleRefused, err.Error() return m, true @@ -264,7 +265,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi m.State, m.Fp, m.Digest = nec.ModuleComposed, comp.FP, dig if before, ok := was[module]; !ok { m.Changed = true - } else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig { + } else if d, err := engcomp.DigestOf(before); err != nil || d != dig { m.Changed = true } return m, true @@ -426,7 +427,7 @@ func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (m // composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build // it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when // given, is handed the scope. -func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope, map[string]string)) (map[string]delivery.Composed, map[string]string, error) { +func (c *Composer) composeShadow(ctx context.Context, running []engcomp.Composed, laid map[string]bool, input func(frombus.Scope, map[string]string)) (map[string]engcomp.Composed, map[string]string, error) { node := c.env.Node c.mu.Lock() placement, placed := c.placements[node] diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index 9b8ed93..18433af 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -74,7 +74,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { var applied atomic.Int64 env.Refused = func(string, map[string]string) { applied.Add(1) } var handed atomic.Int64 - s := NewShadow(env, func(context.Context, map[string]delivery.CandidateAnswer) { handed.Add(1) }) + s := NewShadow(env, func(context.Context, map[string]nodeengine.CandidateAnswer) { handed.Add(1) }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { _ = s.Run(ctx) }() diff --git a/internal/selfcompose/composedstate.go b/internal/selfcompose/composedstate.go index 8da3ea6..5288b12 100644 --- a/internal/selfcompose/composedstate.go +++ b/internal/selfcompose/composedstate.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "sort" "strings" @@ -17,7 +18,7 @@ import ( ) // **The compositions this node makes, published in its composition state** (novox/hq ADR 0593 §6). The node's engine -// alone composes, and it writes every composition it makes into `composed-` (delivery.ComposedBucketFor), a +// alone composes, and it writes every composition it makes into `composed-` (engcomp.BucketFor), a // record for the provisioner, the assigner and the view, never an instruction: the engine applies only what it composes // itself, never a value read from the state, so a value carries no signature. After a pass that composed, it writes: // @@ -30,7 +31,7 @@ import ( // - a module refused alone at a fingerprint as that key with its refusal (Refused), and no resources. // // A key is written only when its value differs from what the state holds (read once per run), and a module's key is -// deleted once its fingerprint is not among the five it wrote last (delivery.ComposedKeep) and no want names it. A pass +// deleted once its fingerprint is not among the five it wrote last (engcomp.Keep) and no want names it. A pass // refused whole, or a bus that did not answer, writes nothing: what was written stands, and each module's have says why. // ComposedStore is this node's composition state, bound and never created (the grants part creates it). @@ -51,7 +52,7 @@ type ComposedEntry struct { type published struct { scope frombus.Scope fps map[string]string // by module, the wanted fingerprint - comps map[string]delivery.Composed + comps map[string]engcomp.Composed held map[string]string // placed is the modules the node's placement puts here, ran what runs of each module (by its composition's FP). placed []string @@ -151,7 +152,7 @@ func (c *Composer) writeComposed(ctx context.Context, at *published) (wrote, del func (c *Composer) composedValues(ctx context.Context, at *published) (map[string][]byte, error) { r := reader{c: c} out := map[string][]byte{} - put := func(comp delivery.Composed, plans map[string]catalogue.Plan) error { + put := func(comp engcomp.Composed, plans map[string]catalogue.Plan) error { if comp.Module != "" && comp.Refused == "" { if p, ok := plans[comp.Module]; ok { p := p @@ -162,7 +163,7 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin if err != nil { return err } - raw, err := delivery.EncodeComposed(comp) + raw, err := engcomp.Encode(comp) if err != nil { return fmt.Errorf("the composition %s cannot be written: %w", key, err) } @@ -172,7 +173,7 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin runs := []struct { scope frombus.Scope fps map[string]string - comps map[string]delivery.Composed + comps map[string]engcomp.Composed held map[string]string }{{at.scope, at.fps, at.comps, at.held}} // The newest builds, composed only where they differ from the wanted ones. @@ -209,7 +210,7 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin runs = append(runs, struct { scope frombus.Scope fps map[string]string - comps map[string]delivery.Composed + comps map[string]engcomp.Composed held map[string]string }{scope, newest, comps, held}) } @@ -234,7 +235,7 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin if !ok || module == agent.NodeModule { continue } - if err := put(delivery.Composed{Node: c.env.Node, Module: module, FP: fp, Refused: why}, nil); err != nil { + if err := put(engcomp.Composed{Node: c.env.Node, Module: module, FP: fp, Refused: why}, nil); err != nil { return nil, err } } @@ -243,15 +244,15 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin } // composedKeyOf is a composition's key in the state. -func composedKeyOf(comp delivery.Composed) (string, error) { +func composedKeyOf(comp engcomp.Composed) (string, error) { if comp.Module == "" { - return delivery.ComposedNodeKey(comp.Node) + return engcomp.NodeKey(comp.Node) } - return delivery.ComposedKey(comp.Node, comp.Module, comp.FP) + return engcomp.Key(comp.Node, comp.Module, comp.FP) } // pastKeeping is, of the module keys the state holds, those whose fingerprint is not among the five its module wrote -// last (delivery.ComposedKeep, by revision) and that no want names (ADR 0593 §6). Under pubWrite. +// last (engcomp.Keep, by revision) and that no want names (ADR 0593 §6). Under pubWrite. func (c *Composer) pastKeeping() []string { c.mu.Lock() wanted := map[string]string{} @@ -266,7 +267,7 @@ func (c *Composer) pastKeeping() []string { } byModule := map[string][]kept{} for key, e := range c.pubKept { - _, module, fp, isNode, err := delivery.ParseComposedKey(key) + _, module, fp, isNode, err := engcomp.ParseKey(key) if err != nil || isNode { continue } @@ -276,7 +277,7 @@ func (c *Composer) pastKeeping() []string { for module, ks := range byModule { sort.Slice(ks, func(i, j int) bool { return ks[i].rev > ks[j].rev }) for i, k := range ks { - if i < delivery.ComposedKeep || wanted[module] == k.fp { + if i < engcomp.Keep || wanted[module] == k.fp { continue } out = append(out, k.key) @@ -309,7 +310,7 @@ func (c *Composer) sayComposed(line string) { } // ranOf is, of what runs, each module's fingerprint by its composition (the node's own left out). -func ranOf(running []delivery.Composed) map[string]string { +func ranOf(running []engcomp.Composed) map[string]string { out := map[string]string{} for _, comp := range running { if comp.Module != "" && comp.FP != "" && !strings.EqualFold(comp.Module, agent.NodeModule) { diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index 0ea6d69..48d3211 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -2,6 +2,7 @@ package selfcompose import ( "context" + engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "strings" "sync" @@ -100,8 +101,8 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { } for u := next(t, out); !u.Caught; u = next(t, out) { } - lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fp) - oldieKey, _ := delivery.ComposedKey("n1", "oldie", ofp) + lemursKey, _ := engcomp.Key("n1", "lemurs", fp) + oldieKey, _ := engcomp.Key("n1", "oldie", ofp) waitFor := func(what string, ok func([]string) bool) []string { t.Helper() deadline := time.Now().Add(5 * time.Second) @@ -122,7 +123,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { t.Errorf("the first pass wrote %v, want the wanted module's key and the node's own", first) } if raw := state.kv[lemursKey].Value; raw != nil { - comp, err := delivery.DecodeComposed(raw) + comp, err := engcomp.Decode(raw) if err != nil || comp.Module != "lemurs" || comp.FP != fp || len(comp.Resources) == 0 { t.Errorf("the published composition of lemurs: %+v, %v", comp, err) } @@ -158,7 +159,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { keep.pubKept = map[string]ComposedEntry{} var keys []string for i, ch := range "123456" { - key, _ := delivery.ComposedKey("n1", "lemurs", delivery.SourcePrefix2+strings.Repeat(string(ch), 64)) + key, _ := engcomp.Key("n1", "lemurs", delivery.SourcePrefix2+strings.Repeat(string(ch), 64)) keys = append(keys, key) keep.pubKept[key] = ComposedEntry{Revision: uint64(10 + i)} } diff --git a/internal/selfcompose/data.go b/internal/selfcompose/data.go index 6c1e86d..fb57685 100644 --- a/internal/selfcompose/data.go +++ b/internal/selfcompose/data.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "strings" "time" @@ -126,7 +127,7 @@ func (c *Composer) sayData(line string) { func (c *Composer) digestsByModule() map[string]string { out := map[string]string{} for key, dig := range c.handed { - _, module, _, isNode, err := delivery.ParseComposedKey(key) + _, module, _, isNode, err := engcomp.ParseKey(key) if err != nil { continue } diff --git a/internal/selfcompose/data_test.go b/internal/selfcompose/data_test.go index d88f0a1..c92e25b 100644 --- a/internal/selfcompose/data_test.go +++ b/internal/selfcompose/data_test.go @@ -3,6 +3,7 @@ package selfcompose import ( "context" "errors" + engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "strings" "sync" @@ -89,8 +90,8 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { t.Error("the node's grant reads other keys of the engine's state data than its own") } c := New(env) - webKey, _ := delivery.ComposedKey("n1", "web", delivery.SourcePrefix2+strings.Repeat("a", 64)) - nodeKey, _ := delivery.ComposedNodeKey("n1") + webKey, _ := engcomp.Key("n1", "web", delivery.SourcePrefix2+strings.Repeat("a", 64)) + nodeKey, _ := engcomp.NodeKey("n1") for _, step := range []struct { name string due bool @@ -183,8 +184,8 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { // the key again, so a key lost on the bus is written back. Another node's key is never touched. pkgStore := &fakeData{kv: map[string][]byte{"n2": []byte(`{"node":"n2","packages":[]}`)}} pc := New(Env{Node: "n1", Packages: pkgStore}) - web := func(pkgs ...string) delivery.Composed { - comp := delivery.Composed{Node: "n1", Module: "web"} + web := func(pkgs ...string) engcomp.Composed { + comp := engcomp.Composed{Node: "n1", Module: "web"} for _, p := range pkgs { comp.Resources = append(comp.Resources, []byte(`{"id":"`+p+`","type":"package","package":"`+p+`"}`)) } @@ -193,25 +194,25 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { } for _, step := range []struct { name string - comp delivery.Composed + comp engcomp.Composed fail error puts int owed bool holds string }{ {name: "the run's first pass writes the key", comp: web("curl"), puts: 1, - holds: `{"node":"n1","packages":[{"package":"curl","modules":["web"],"absent":false}]}`}, + holds: `{"node":"n1","packages":[{"absent":false,"modules":["web"],"package":"curl"}]}`}, {name: "a pass that changes no package writes nothing", comp: web("curl"), puts: 1}, {name: "a refused write is owed after the bound", comp: web("curl", "jq"), fail: errors.New("no grant yet"), puts: 1, owed: true}, {name: "a composition adding a package writes the key once", comp: web("curl", "jq"), puts: 2, - holds: `{"node":"n1","packages":[{"package":"curl","modules":["web"],"absent":false},{"package":"jq","modules":["web"],"absent":false}]}`}, + holds: `{"node":"n1","packages":[{"absent":false,"modules":["web"],"package":"curl"},{"absent":false,"modules":["web"],"package":"jq"}]}`}, } { pkgStore.mu.Lock() pkgStore.fail = step.fail pkgStore.mu.Unlock() pc.paid() pc.mu.Lock() - pc.pkgs, pc.pkgsComposed, pc.pkgsDue = map[string]delivery.Composed{webKey: step.comp}, true, true + pc.pkgs, pc.pkgsComposed, pc.pkgsDue = map[string]engcomp.Composed{webKey: step.comp}, true, true pc.mu.Unlock() pc.publishPackages(context.Background()) if len(pkgStore.puts) != step.puts || pc.owed.Load() != step.owed || diff --git a/internal/selfcompose/packages.go b/internal/selfcompose/packages.go index 38a3a11..45b4801 100644 --- a/internal/selfcompose/packages.go +++ b/internal/selfcompose/packages.go @@ -6,10 +6,11 @@ import ( "encoding/json" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // **The packages this node's compositions declare, kept by its engine** (novox/hq ADR 0565 §6, §15; issue 1953). The @@ -28,8 +29,8 @@ type PackagesStore interface { } // packagesOf is a composition's package resources alone, with its module: what the node's key is worked out from. -func packagesOf(comp delivery.Composed) delivery.Composed { - out := delivery.Composed{Node: comp.Node, Module: comp.Module} +func packagesOf(comp engcomp.Composed) engcomp.Composed { + out := engcomp.Composed{Node: comp.Node, Module: comp.Module} for _, raw := range comp.Resources { var head struct { Type string `json:"type"` @@ -42,24 +43,24 @@ func packagesOf(comp delivery.Composed) delivery.Composed { } // DeclaredPackages is the node's key as this composer would write it now, from what it handed. -func (c *Composer) DeclaredPackages() (delivery.DeclaredPackages, error) { +func (c *Composer) DeclaredPackages() (nodeengine.PackagesByNodeValue, error) { c.mu.Lock() keys := make([]string, 0, len(c.pkgs)) for k := range c.pkgs { keys = append(keys, k) } sort.Strings(keys) - comps := make([]delivery.Composed, 0, len(keys)) + comps := make([]engcomp.Composed, 0, len(keys)) for _, k := range keys { comps = append(comps, c.pkgs[k]) } composed := c.pkgsComposed c.mu.Unlock() if !composed { - return delivery.DeclaredPackages{}, errors.New("this node's composer has handed no composition yet in this run, so it " + + return nodeengine.PackagesByNodeValue{}, errors.New("this node's composer has handed no composition yet in this run, so it " + "declares no packages yet") } - return delivery.DeclaredPackagesOf(c.env.Node, comps) + return engcomp.DeclaredPackages(c.env.Node, comps) } // publishPackages writes the node's key of the state packages when a pass asked it (pkgsDue) and the value differs. @@ -86,7 +87,7 @@ func (c *Composer) publishPackages(ctx context.Context) { } // enginePackagesBucket is the engine's state packages as the bus names it, for what the composer says. -const enginePackagesBucket = "mesh-host_" + delivery.EnginePackagesState +const enginePackagesBucket = engcomp.PackagesBucket // WritePackages writes the node's key again from what the composer handed, when it differs from what the bus holds // now (the node seat's verb packages with write, ADR 0565 §15): the key is read again rather than trusted, so a key lost diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index e9e9c0f..4b115df 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "github.com/novox/mesh-host/internal/compositions" + engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "reflect" "slices" @@ -71,7 +72,7 @@ type Env struct { // switch to composing its own nor a restart changes a composition (ADR 0547 §3); a box held at another slot is // never taken, though it opens to the same value (issue 1126, C1: a provider's grant and its consumer's secret). // Either nil keeps the vault's box. - Running func() []delivery.Composed + Running func() []compositions.Composed Unseal func(sealed string) ([]byte, error) // Held is the sealed values a module's have says it runs, kept by the agent on the bus: what runs after a restart, // before the agent applied anything, and after the switch once the kept declaration is old. By slot @@ -195,7 +196,7 @@ type Composer struct { // pkgs is the package resources of each composition handed, by its composed key, and pkgsComposed whether this run // handed any (ADR 0565 §6); pkgsDue, pkgsTries and pkgsSaid are as the data items' are. Under mu. pkgsKept is what // the node's key of the state packages held when last read or written (pkgsRead), under pkgsWrite. - pkgs map[string]delivery.Composed + pkgs map[string]compositions.Composed pkgsComposed bool pkgsDue bool pkgsTries int @@ -299,7 +300,7 @@ func New(env Env) *Composer { return &Composer{env: env, wants: map[string]delivery.Want{}, placements: map[string]delivery.Placement{}, placementKeys: delivery.NewPlacementKeys(), records: map[string]delivery.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{}, manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]delivery.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, - pkgs: map[string]delivery.Composed{}, asked: make(chan chan Pass, 8)} + pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} } // ComposeNow asks the composer for a pass now (the node-engine's verb compose, issue 774): the growing bound after a seat @@ -880,7 +881,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } // What it runs: a folded module of an old-form build is carried in its own composition as it runs (issue 1126). - var running []delivery.Composed + var running []compositions.Composed if c.env.Running != nil { running = c.env.Running() } @@ -999,7 +1000,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { continue } now[key] = true - dig, err := delivery.ComposedDigestOf(comp) + dig, err := engcomp.DigestOf(comp) if err != nil { c.env.Say(fmt.Sprintf("the composition %s has no digest, and is not handed: %v", key, err)) continue @@ -1007,7 +1008,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { if c.handed[key] == dig { continue } - raw, err := delivery.EncodeComposed(comp) + raw, err := engcomp.Encode(comp) if err != nil { c.env.Say(fmt.Sprintf("the composition %s cannot be written, and is not handed: %v", key, err)) continue @@ -1018,7 +1019,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { out = append(out, agent.Update{Key: key, Value: raw, Revision: c.rev}) } for key := range c.handed { - if _, module, _, _, err := delivery.ParseComposedKey(key); err == nil && module != "" { + if _, module, _, _, err := engcomp.ParseKey(key); err == nil && module != "" { if _, isHeld := held[module]; isHeld { continue // held: what was handed of it stays, so the node keeps running it } @@ -1038,7 +1039,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { placedMods = append(placedMods, m.Module) } sort.Strings(placedMods) - pubComps := make(map[string]delivery.Composed, len(compositions)) + pubComps := make(map[string]engcomp.Composed, len(compositions)) for key, comp := range compositions { if comp.Module == "" && !nodeWanted { continue @@ -1097,7 +1098,7 @@ func (c *Composer) heldNow() []string { // value (compositions.KeepSeals, issue 1126, C1): of what runs (running, the agent's), else of what its module's have says // it runs (Held). A have written before slots names no slot: its boxes are reused within their own module alone, at a // slot no box is kept for, and only one opening to the slot's value. -func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Composed) delivery.Composed { +func (c *Composer) keepRunning(comp compositions.Composed, running []compositions.Composed) compositions.Composed { if c.env.Unseal == nil { return comp } @@ -1731,7 +1732,7 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string if !placed { return "", false } - var running []delivery.Composed + var running []compositions.Composed if c.env.Running != nil { running = c.env.Running() } @@ -1742,7 +1743,7 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string } for _, comp := range comps { if comp.Module == module && comp.FP == fp { - dig, err := delivery.ComposedDigestOf(c.keepRunning(comp, running)) + dig, err := compositions.DigestOf(c.keepRunning(comp, running)) return dig, err == nil } } @@ -1750,7 +1751,7 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string } // ownProvisioner is the provisioner client's account the node's own composition among cs names, or "". -func ownProvisioner(cs []delivery.Composed) string { +func ownProvisioner(cs []compositions.Composed) string { for _, c := range cs { if c.Module == "" { return c.Own.Provisioner @@ -1759,8 +1760,8 @@ func ownProvisioner(cs []delivery.Composed) string { return "" } -func mapValues(m map[string]delivery.Composed) []delivery.Composed { - out := make([]delivery.Composed, 0, len(m)) +func mapValues(m map[string]compositions.Composed) []compositions.Composed { + out := make([]compositions.Composed, 0, len(m)) for _, k := range slices.Sorted(maps.Keys(m)) { out = append(out, m[k]) } @@ -1778,18 +1779,18 @@ const WaitsForTheSeatListOwn = "the node has not read the seat list, so it compo // digest) while the reader may not act on its seats, and names the module in held: it keeps running what it runs, and // the pass after the list was read hands it. The node's own composition is held the same way, whatever it folds, under // the scope agent.NodeModule; a composition that runs already is left as it is. -func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions map[string]delivery.Composed, held map[string]string, running []delivery.Composed) map[string]string { +func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions map[string]compositions.Composed, held map[string]string, running []compositions.Composed) map[string]string { if c.env.SeatList == nil { return held } ran := map[string]bool{} for _, comp := range running { - if dig, err := delivery.ComposedDigestOf(comp); err == nil { + if dig, err := engcomp.DigestOf(comp); err == nil { ran[comp.Module+"/"+dig] = true } } for key, comp := range compositions { - if dig, err := delivery.ComposedDigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { + if dig, err := engcomp.DigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { continue } module, why := comp.Module, WaitsForTheSeatList diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index eef2e64..7ae5ad3 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -172,7 +173,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { return secretChanged, func() {}, nil } var composed atomic.Int64 // passes made: each reads what runs once - env.Running = func() []delivery.Composed { composed.Add(1); return nil } + env.Running = func() []compositions.Composed { composed.Add(1); return nil } c := New(env) out, err := c.Watch(ctx, "n1.>") if err != nil { @@ -204,13 +205,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.send(bucket, agent.Update{Caught: true}) } got := map[string]bool{} - var own delivery.Composed + var own compositions.Composed for { u := next(t, out) if u.Caught { break } - c, err := delivery.DecodeComposed(u.Value) + c, err := compositions.Decode(u.Value) if err != nil { t.Fatalf("%s: %v", u.Key, err) } @@ -261,8 +262,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Errorf("a composer that watches nothing answered compose now: %v", err) } icancel() - lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fp) - nodeKey, _ := delivery.ComposedNodeKey("n1") + lemursKey, _ := compositions.Key("n1", "lemurs", fp) + nodeKey, _ := compositions.NodeKey("n1") if !got[lemursKey] || !got[nodeKey] || len(got) != 2 { b.mu.Lock() t.Logf("said: %v", b.said) @@ -436,14 +437,14 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { r3 := c3.records["n1"] r3.Capabilities = []string{"systemd", "container-runtime"} c3.records["n1"] = r3 - spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", c3.wants["de-spiegel"].FP) - proxyKey, _ := delivery.ComposedKey("n1", "proxy", c3.wants["proxy"].FP) + spiegelKey, _ := compositions.Key("n1", "de-spiegel", c3.wants["de-spiegel"].FP) + proxyKey, _ := compositions.Key("n1", "proxy", c3.wants["proxy"].FP) c3.handed[spiegelKey] = "sha256:" + strings.Repeat("5", 64) // what the node runs of it, handed before handed3 := map[string]agent.Update{} for _, u := range c3.compose(ctx) { handed3[u.Key] = u } - lemursKey3, _ := delivery.ComposedKey("n1", "lemurs", c3.wants["lemurs"].FP) + lemursKey3, _ := compositions.Key("n1", "lemurs", c3.wants["lemurs"].FP) if u, ok := handed3[lemursKey3]; !ok || u.Deleted { t.Errorf("lemurs, which composes, is not handed beside de-spiegel's refusal: %v (told %q)", handed3, *told) } @@ -507,7 +508,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { p5.Decided.Endpoint = "n1.example:51820" c5.placements["n1"] = p5 c5.inOwn[overlay.Name] = true - nodeKey5, _ := delivery.ComposedNodeKey("n1") + nodeKey5, _ := compositions.NodeKey("n1") var own5 string for _, u := range c5.compose(ctx) { if u.Key == nodeKey5 { @@ -678,8 +679,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { defer hmu.Unlock() held = append(held, why+fmt.Sprint(h)) } - tenv.Running = func() []delivery.Composed { - return []delivery.Composed{{Node: "n1", Module: "tool", FP: old, Resources: []json.RawMessage{ + tenv.Running = func() []compositions.Composed { + return []compositions.Composed{{Node: "n1", Module: "tool", FP: old, Resources: []json.RawMessage{ json.RawMessage(`{"id":"service","module":"tool","type":"service","unit":"tool.service","state":"running","boot":"enabled"}`)}}} } tc := New(tenv) @@ -702,7 +703,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { ResolvedBucket, delivery.BuilderBuildsBucket} { tb.send(bucket, agent.Update{Caught: true}) } - toolKey, _ := delivery.ComposedKey("n1", "tool", next2) + toolKey, _ := compositions.Key("n1", "tool", next2) for u := next(t, tout); !u.Caught; u = next(t, tout) { if u.Key == toolKey { t.Errorf("tool is handed at a build not yet done") @@ -781,7 +782,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } } b.put(t, delivery.AssignerWantsBucket, "want.n1.keeper", want) - keeperKey, _ := delivery.ComposedKey("n1", "keeper", fp) + keeperKey, _ := compositions.Key("n1", "keeper", fp) var handed string for handed == "" { if u := next(t, out); u.Key == keeperKey { @@ -823,7 +824,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // or another resource of keeper holds is never taken, though it opens to the same value; a have written before slots // (a list) is reused within its own module alone. boxOf := func(raw string) (string, string) { - c, err := delivery.DecodeComposed([]byte(raw)) + c, err := compositions.Decode([]byte(raw)) if err != nil { t.Fatal(err) } @@ -852,7 +853,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { {"the bus does not answer for the have", "keeper", rotatedBox, nil, false, true}, } { restarted := env - restarted.Running = func() []delivery.Composed { return nil } + restarted.Running = func() []compositions.Composed { return nil } restarted.Held = func(module string) (compositions.Seals, []string, error) { if module != row.in { return nil, nil, nil @@ -917,7 +918,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // And the node's own key (review of L2, round 3, finding 1): a core box its own composition carries is held there, and // kept after a restart as a module's is, so the node's own composition does not move either. - ownComp := delivery.Composed{Node: "n1", Resources: []json.RawMessage{json.RawMessage(`{"id":"core-box","sealed":"{\"ask\":999}"}`)}} + ownComp := compositions.Composed{Node: "n1", Resources: []json.RawMessage{json.RawMessage(`{"id":"core-box","sealed":"{\"ask\":999}"}`)}} b.mu.Lock() sealedAt[`{"ask":999}`] = generation + 1 b.mu.Unlock() @@ -946,8 +947,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { sealedAt["box-again"] = sealedAt[rotatedBox] sealedAt["box-other"] = sealedAt[rotatedBox] + 100 b.mu.Unlock() - ini := func(db, key string) delivery.Composed { - return delivery.Composed{Node: "n1", Module: "grafana", Resources: []json.RawMessage{json.RawMessage( + ini := func(db, key string) compositions.Composed { + return compositions.Composed{Node: "n1", Module: "grafana", Resources: []json.RawMessage{json.RawMessage( `{"content":"[database]\npassword = ${secret:postgres-database}\n[security]\nsecret_key = ${secret:secret-key}\n",` + `"id":"grafana.ini","mode":"0600","module":"grafana","path":"/etc/grafana/grafana.ini","secrets":{"postgres-database":` + strconv.Quote(db) + `,"secret-key":` + strconv.Quote(key) + `},"type":"file"}`)}} @@ -985,8 +986,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // The node runs the consumer's credential already, sealed otherwise (the controller sealed it, or the vault at // another ask): that box opens to the same value, so it is kept, and the composition does not move. - pairEnv.Running = func() []delivery.Composed { - return []delivery.Composed{{Module: "web", Resources: []json.RawMessage{json.RawMessage(`{"id":"web.secret-pg","sealed":"{\"run\":1}"}`)}}} + pairEnv.Running = func() []compositions.Composed { + return []compositions.Composed{{Module: "web", Resources: []json.RawMessage{json.RawMessage(`{"id":"web.secret-pg","sealed":"{\"run\":1}"}`)}}} } opened := map[string]string{`{"c":1}`: "pg-pw", `{"run":1}`: "pg-pw", `{"p":1}`: "s3-pw"} pairEnv.Unseal = func(box string) ([]byte, error) { @@ -1009,7 +1010,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Errorf("credentials %+v issued %+v", creds, issued) } // What runs at the credential's slot is kept, after the composition (keepRunning, issue 1126, C1). - webComp := delivery.Composed{Node: "n1", Module: "web", Resources: []json.RawMessage{json.RawMessage(`{"id":"web.secret-pg","sealed":"{\"c\":1}"}`)}} + webComp := compositions.Composed{Node: "n1", Module: "web", Resources: []json.RawMessage{json.RawMessage(`{"id":"web.secret-pg","sealed":"{\"c\":1}"}`)}} if got := compositions.SealsIn(pc.keepRunning(webComp, pairEnv.Running()))["web.secret-pg#sealed"]; got != `{"run":1}` { t.Errorf("web's credential composed as %s, want the box that runs", got) } @@ -1053,8 +1054,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - senv.Running = func() []delivery.Composed { - var out []delivery.Composed + senv.Running = func() []compositions.Composed { + var out []compositions.Composed for _, c := range running { out = append(out, c) } @@ -1062,9 +1063,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // The controller's compositions at n1's wants, in n1's composition bucket: what a head deleting its placement // returns it to (the seed's revert); here the ones it runs. - composedBucket, _ := delivery.ComposedBucketFor("n1") + composedBucket, _ := compositions.BucketFor("n1") for key, c := range running { - raw, err := delivery.EncodeComposed(c) + raw, err := compositions.Encode(c) if err != nil { t.Fatal(err) } @@ -1072,8 +1073,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { readOnly.kv[composedBucket+"/"+key] = raw readOnly.mu.Unlock() } - answered := make(chan map[string]delivery.CandidateAnswer, 8) - sh := NewShadow(senv, func(_ context.Context, a map[string]delivery.CandidateAnswer) { answered <- a }) + answered := make(chan map[string]nodeengine.CandidateAnswer, 8) + sh := NewShadow(senv, func(_ context.Context, a map[string]nodeengine.CandidateAnswer) { answered <- a }) sctx, scancel := context.WithCancel(ctx) defer scancel() // Review of L2b, round 1, finding 2: the candidates' state is not there when the node starts (a delivery before the @@ -1096,7 +1097,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { var lemursDigest string for _, c := range running { if c.Module == "lemurs" { - lemursDigest, _ = delivery.ComposedDigestOf(c) + lemursDigest, _ = compositions.DigestOf(c) } } wantRaw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: lemursDigest}) @@ -1142,8 +1143,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { sb.put(delivery.PlacementCandidatesBucket, k, raw, 8) } sb.caught() - var answers map[string]delivery.CandidateAnswer - waitFor := func(why string, ok func(map[string]delivery.CandidateAnswer) bool) { + var answers map[string]nodeengine.CandidateAnswer + waitFor := func(why string, ok func(map[string]nodeengine.CandidateAnswer) bool) { t.Helper() deadline := time.After(10 * time.Second) for answers == nil || !ok(answers) { @@ -1154,7 +1155,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } } } - waitFor("every group answered", func(a map[string]delivery.CandidateAnswer) bool { return len(a) == 6 }) + waitFor("every group answered", func(a map[string]nodeengine.CandidateAnswer) bool { return len(a) == 6 }) // Every watch and get the shadow makes is one the node's grant allows (issue 1126, 2026-10-27 09:21). sb.mu.Lock() for _, w := range sb.watches { @@ -1170,28 +1171,28 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } } readOnly.mu.Unlock() - for group, want := range map[string]func(delivery.CandidateAnswer) bool{ - "same": func(a delivery.CandidateAnswer) bool { - return a.Verdict == delivery.CandidateSame && a.Proposal == "same" && a.Revision == 3 && len(a.Same) == 2 + for group, want := range map[string]func(nodeengine.CandidateAnswer) bool{ + "same": func(a nodeengine.CandidateAnswer) bool { + return a.Verdict == nodeengine.CandidateAnswerVerdictSame && a.Proposal == "same" && a.Revision == 3 && len(a.Same) == 2 }, - "adds": func(a delivery.CandidateAnswer) bool { - return a.Verdict == delivery.CandidateDiffers && len(a.Added) == 1 && a.Added[0].Module == "keeper" && + "adds": func(a nodeengine.CandidateAnswer) bool { + return a.Verdict == nodeengine.CandidateAnswerVerdictDiffers && len(a.Added) == 1 && a.Added[0].Module == "keeper" && len(a.Added[0].Unissued) == 1 && len(a.Refusals) == 0 }, - "drops": func(a delivery.CandidateAnswer) bool { - return a.Verdict == delivery.CandidateDiffers && len(a.Removed) == 1 && a.Removed[0] == "lemurs" + "drops": func(a nodeengine.CandidateAnswer) bool { + return a.Verdict == nodeengine.CandidateAnswerVerdictDiffers && len(a.Removed) == 1 && a.Removed[0] == "lemurs" }, - "ghost": func(a delivery.CandidateAnswer) bool { - return a.Verdict == delivery.CandidateRefused && a.Refused != "" + "ghost": func(a nodeengine.CandidateAnswer) bool { + return a.Verdict == nodeengine.CandidateAnswerVerdictRefused && a.Refused != "" }, - "reach": func(a delivery.CandidateAnswer) bool { - return a.Verdict == delivery.CandidateSame && a.Returns == "" && a.Revision == 8 && len(a.Removed) == 0 && + "reach": func(a nodeengine.CandidateAnswer) bool { + return a.Verdict == nodeengine.CandidateAnswerVerdictSame && a.Returns == "" && a.Revision == 8 && len(a.Removed) == 0 && fmt.Sprint(a.Same) == "[lemurs node]" }, // Issue 1126, A7: a candidate deleting the node's placement is refused, never answered as a return to the // controller's compositions: no composition made elsewhere is applied, so the node would compose nothing. - "deletes": func(a delivery.CandidateAnswer) bool { - return a.Verdict == delivery.CandidateRefused && a.Returns == "" && a.Revision == 7 && + "deletes": func(a nodeengine.CandidateAnswer) bool { + return a.Verdict == nodeengine.CandidateAnswerVerdictRefused && a.Returns == "" && a.Revision == 7 && strings.Contains(a.Refused, "composes nothing") }, } { @@ -1252,7 +1253,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Errorf("answered %d more time(s) with no candidate revision and no input of the node changed", got-settled) } candidate("same", 8, "lemurs") - waitFor("the new revision answered", func(a map[string]delivery.CandidateAnswer) bool { return a["same"].Revision == 8 }) + waitFor("the new revision answered", func(a map[string]nodeengine.CandidateAnswer) bool { return a["same"].Revision == 8 }) drain() if got := answering(); got != settled+1 { t.Errorf("a new revision of one group answered %d group(s), want 1", got-settled) @@ -1286,12 +1287,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { setFor(t, &laterAgain, []time.Duration{20 * time.Millisecond}) ownFails.Store(true) candidate("adds", 9, "lemurs", "keeper") - waitFor("the answer met the vault not answering", func(a map[string]delivery.CandidateAnswer) bool { + waitFor("the answer met the vault not answering", func(a map[string]nodeengine.CandidateAnswer) bool { return a["adds"].Revision == 9 && strings.Contains(a["adds"].Refused, "no responders") }) ownFails.Store(false) - waitFor("answered again once the bus answers, with nothing else moved", func(a map[string]delivery.CandidateAnswer) bool { - return a["adds"].Revision == 9 && a["adds"].Verdict == delivery.CandidateDiffers + waitFor("answered again once the bus answers, with nothing else moved", func(a map[string]nodeengine.CandidateAnswer) bool { + return a["adds"].Revision == 9 && a["adds"].Verdict == nodeengine.CandidateAnswerVerdictDiffers }) drain() if got := lastSaid("answering placement candidate adds "); got != "answering placement candidate adds (revision 9; woken by a retry after a read the bus did not answer)" { @@ -1299,7 +1300,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } k, _ := delivery.PlacementCandidateKey("same", "n1") sb.del(delivery.PlacementCandidatesBucket, k) - waitFor("the deleted group's answer stays", func(a map[string]delivery.CandidateAnswer) bool { _, ok := a["same"]; return !ok && len(a) == 5 }) + waitFor("the deleted group's answer stays", func(a map[string]nodeengine.CandidateAnswer) bool { _, ok := a["same"]; return !ok && len(a) == 5 }) // Review of L2b, round 2: a watch ends while the link stays; a group's key is deleted before the shadow watches // again. The new run reads afresh, so that group is no longer answered. @@ -1309,7 +1310,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { sb.del(delivery.PlacementCandidatesBucket, dropsKey) time.Sleep(30 * time.Millisecond) sb.missing(delivery.PlacementCandidatesBucket, false) - waitFor("a group deleted while the shadow was down is still answered", func(a map[string]delivery.CandidateAnswer) bool { + waitFor("a group deleted while the shadow was down is still answered", func(a map[string]nodeengine.CandidateAnswer) bool { _, ok := a["drops"] return !ok && len(a) == 4 }) @@ -1342,8 +1343,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { var nsaid []string var nmu sync.Mutex nenv.Say = func(l string) { nmu.Lock(); nsaid = append(nsaid, l); nmu.Unlock() } - nanswered := make(chan map[string]delivery.CandidateAnswer, 8) - nsh := NewShadow(nenv, func(_ context.Context, a map[string]delivery.CandidateAnswer) { nanswered <- a }) + nanswered := make(chan map[string]nodeengine.CandidateAnswer, 8) + nsh := NewShadow(nenv, func(_ context.Context, a map[string]nodeengine.CandidateAnswer) { nanswered <- a }) nctx, ncancel := context.WithCancel(ctx) defer ncancel() go func() { _ = nsh.Run(nctx) }() @@ -1366,7 +1367,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } select { case a := <-nanswered: - if g := a["same"]; g.Verdict != delivery.CandidateRefused || !strings.Contains(g.Refused, ResolvedBucket) || g.Proposal != "same" { + if g := a["same"]; g.Verdict != nodeengine.CandidateAnswerVerdictRefused || !strings.Contains(g.Refused, ResolvedBucket) || g.Proposal != "same" { t.Errorf("a shadow waiting for its values answered %+v", g) } case <-time.After(5 * time.Second): @@ -1384,14 +1385,14 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { nb.send(ResolvedBucket, agent.Update{Caught: true}) select { case a := <-nanswered: - if g := a["same"]; g.Verdict != delivery.CandidateSame || !strings.Contains(g.Note, "this answer took over") { + if g := a["same"]; g.Verdict != nodeengine.CandidateAnswerVerdictSame || !strings.Contains(g.Note, "this answer took over") { t.Errorf("the shadow, once ready, answered %+v", g) } // The newest first, handed at once: the older group still holds the answer it had. - if g := a["older"]; g.Verdict != delivery.CandidateRefused { + if g := a["older"]; g.Verdict != nodeengine.CandidateAnswerVerdictRefused { t.Errorf("the older group was answered before the newest was handed: %+v", g) } - if g := (<-nanswered)["older"]; g.Verdict != delivery.CandidateSame { + if g := (<-nanswered)["older"]; g.Verdict != nodeengine.CandidateAnswerVerdictSame { t.Errorf("the older group, answered next: %+v", g) } // Issue 1126, T2: an answer says when it starts and when it ends (and, every minute between, what it waits on). @@ -1419,8 +1420,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Issue 1126, probe #532 on shanks: what runs is the composition the have names, never the copy of the declaration // kept before the switch (no fp, no digest): a module composed at the digest its have says runs is the same. { - probe := delivery.CandidateAnswer{Proposal: "pro", Revision: 68, Verdict: delivery.CandidateDiffers, Same: []string{"adwaita"}, - Differs: []delivery.ModuleDiffers{ + probe := nodeengine.CandidateAnswer{Proposal: "pro", Revision: 68, Verdict: nodeengine.CandidateAnswerVerdictDiffers, Same: []string{"adwaita"}, + Differs: []nodeengine.CandidateDiffers{ {Module: "xterm", Composed: "sha256:3c0a31df64283c0678b1cdbeff8ac030609cab65cc7e47faaf714da743d9340e", Why: "the fingerprint differs"}, {Module: "zsh", Composed: "sha256:6093f31056d39eaf604410b7289ec6b5fe602b19c8547283562fcfc078c4844f", Resource: "zsh.bundle-tools", Why: "the resource differs"}, {Module: "brother", Composed: "sha256:0702b13991288da72d230460624e949d3c1d7d833eac2ec2134073c82e5a61cf", @@ -1429,11 +1430,11 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { "xterm": "sha256:3c0a31df64283c0678b1cdbeff8ac030609cab65cc7e47faaf714da743d9340e", "zsh": "sha256:6093f31056d39eaf604410b7289ec6b5fe602b19c8547283562fcfc078c4844f", "brother": "sha256:c11c14af2738e3fcdb04da4004bbe84728bdefaf8c986d76601ffbe502435871"}) - if fmt.Sprint(got.Same) != "[adwaita xterm zsh]" || len(got.Differs) != 1 || got.Differs[0].Module != "brother" || got.Verdict != delivery.CandidateDiffers { + if fmt.Sprint(got.Same) != "[adwaita xterm zsh]" || len(got.Differs) != 1 || got.Differs[0].Module != "brother" || got.Verdict != nodeengine.CandidateAnswerVerdictDiffers { t.Errorf("shanks' probe answered %+v", got) } - if one := asHavesSay(delivery.CandidateAnswer{Proposal: "pro", Revision: 1, Verdict: delivery.CandidateDiffers, Differs: probe.Differs[:1]}, - map[string]string{"xterm": probe.Differs[0].Composed}); one.Verdict != delivery.CandidateSame { + if one := asHavesSay(nodeengine.CandidateAnswer{Proposal: "pro", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictDiffers, Differs: probe.Differs[:1]}, + map[string]string{"xterm": probe.Differs[0].Composed}); one.Verdict != nodeengine.CandidateAnswerVerdictSame { t.Errorf("a module the same as its have says is answered %+v", one) } } @@ -1441,7 +1442,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Issue 1126, the seed's review: a module n1 runs inside its own composition (nats, at a build of the old form) is // composed there again, not at the trunk's build as one added; lemurs at the build it runs, keeper (added) at the // trunk's. - inOwnRunning := []delivery.Composed{{Node: "n1", Module: "lemurs", FP: fp}} + inOwnRunning := []compositions.Composed{{Node: "n1", Module: "lemurs", FP: fp}} other := "src2:" + strings.Repeat("9", 64) gotFps := shadowBuilds(inOwnRunning, delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "nats"}, {Module: "keeper"}}}, map[string]delivery.Module{"lemurs": {FP2: other}, "nats": {FP2: other}, "keeper": {FP2: other}}, []string{"nats"}) diff --git a/internal/selfcompose/shadow.go b/internal/selfcompose/shadow.go index 4feed7c..85a539d 100644 --- a/internal/selfcompose/shadow.go +++ b/internal/selfcompose/shadow.go @@ -3,7 +3,10 @@ package selfcompose import ( "bytes" "context" + "encoding/json" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "strings" "sync" @@ -29,11 +32,11 @@ import ( // keys go. type Shadow struct { c *Composer - answer func(ctx context.Context, answers map[string]delivery.CandidateAnswer) + answer func(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) mu sync.Mutex candidates map[string]map[string]candidateKey // by proposal, by node - answers map[string]delivery.CandidateAnswer + answers map[string]nodeengine.CandidateAnswer // ran is, by module, the digest the module's have says runs: what runs, where the agent's list holds only the copy // from the declaration kept before the switch (issue 1126, probe #532 on shanks). ran map[string]string @@ -99,9 +102,9 @@ type candidateKey struct { } // NewShadow is the shadow composer of one node; answer writes the node's answers into its own have key. -func NewShadow(env Env, answer func(ctx context.Context, answers map[string]delivery.CandidateAnswer)) *Shadow { +func NewShadow(env Env, answer func(ctx context.Context, answers map[string]nodeengine.CandidateAnswer)) *Shadow { return &Shadow{c: New(env), answer: answer, candidates: map[string]map[string]candidateKey{}, - answers: map[string]delivery.CandidateAnswer{}, ran: map[string]string{}} + answers: map[string]nodeengine.CandidateAnswer{}, ran: map[string]string{}} } // Keep runs the shadow until ctx ends, beginning again after backoff(try) whenever it stops: an input it cannot watch @@ -341,13 +344,13 @@ func (s *Shadow) notReady(ctx context.Context, ins []input, caught []bool) { why := fmt.Sprintf("the node has not read %s after %s; it answers once it has", strings.Join(waits, ", "), notReadyAfter) s.c.env.Say("this node answers no placement candidate yet: " + why) s.mu.Lock() - next := map[string]delivery.CandidateAnswer{} + next := map[string]nodeengine.CandidateAnswer{} for proposal, keys := range s.candidates { own, ok := keys[s.c.env.Node] if !ok { continue // no key of this node's: nothing asks it to answer } - next[proposal] = delivery.CandidateAnswer{Proposal: proposal, Revision: own.rev, Refused: why, Verdict: delivery.CandidateRefused} + next[proposal] = nodeengine.CandidateAnswer{Proposal: proposal, Revision: int64(own.rev), Refused: why, Verdict: nodeengine.CandidateAnswerVerdictRefused} } s.answers = next s.mu.Unlock() @@ -386,7 +389,7 @@ func (s *Shadow) answerAll(ctx context.Context) { } } // Each proposal's last answer stands until it is answered again; a proposal whose keys went is dropped. - next := map[string]delivery.CandidateAnswer{} + next := map[string]nodeengine.CandidateAnswer{} for _, l := range proposals { if a, ok := s.answers[l.proposal]; ok { next[l.proposal] = a @@ -408,7 +411,7 @@ func (s *Shadow) answerAll(ctx context.Context) { same = false } } - out := make(map[string]delivery.CandidateAnswer, len(next)) + out := make(map[string]nodeengine.CandidateAnswer, len(next)) for g, a := range next { out[g] = a } @@ -417,7 +420,7 @@ func (s *Shadow) answerAll(ctx context.Context) { } s.mu.Unlock() if !same && s.answer != nil { - given := make(map[string]delivery.CandidateAnswer, len(out)) + given := make(map[string]nodeengine.CandidateAnswer, len(out)) for g, a := range out { given[g] = a } @@ -466,7 +469,7 @@ func (s *Shadow) answerAll(ctx context.Context) { } } if ok { - a = judge.FitAnswer(a, delivery.MaxAnswerBytes) + a = judge.FitAnswer(a, engcomp.MaxAnswerBytes) next[proposal] = a c.env.Say(fmt.Sprintf("answered placement candidate %s in %s: %s", proposal, took.Round(time.Millisecond), a.Verdict)) } else { @@ -495,7 +498,7 @@ var progressEvery = time.Minute // answerProposal is the node's answer to one proposal, and false when the node has nothing to answer: neither the trunk nor // the proposal places it. -func (s *Shadow) answerProposal(ctx context.Context, proposal string) (delivery.CandidateAnswer, bool) { +func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengine.CandidateAnswer, bool) { c := s.c node := c.env.Node defer c.waiting("composing")() @@ -520,10 +523,10 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (delivery. } c.mu.Unlock() if !mine { - return delivery.CandidateAnswer{}, false + return nodeengine.CandidateAnswer{}, false } - var running []delivery.Composed + var running []engcomp.Composed if c.env.Running != nil { running = c.env.Running() } @@ -531,7 +534,7 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (delivery. // The proposal deletes this node's placement key: the node would compose nothing and keep running what it runs, // since no composition made elsewhere is applied (issue 1126: the controller ended by its unassignment). Refused, // so the catalogue does not take a node's placement away by a merge. - return delivery.CandidateAnswer{Proposal: proposal, Revision: rev, Verdict: delivery.CandidateRefused, + return nodeengine.CandidateAnswer{Proposal: proposal, Revision: int64(rev), Verdict: nodeengine.CandidateAnswerVerdictRefused, Refused: "the proposal takes this node's placement away: it then composes nothing, and no composition made " + "elsewhere is applied, so what runs here would be followed by nothing"}, true } @@ -635,7 +638,7 @@ func (s *Shadow) takeRan(u agent.Update) bool { // counted the same where the composed digest is the one its have says runs: what runs is the composition at that // digest, never the kept copy (issue 1126, probe #532: shanks' xterm, zsh, zsh-autosuggestions and // zsh-syntax-highlighting). -func asHavesSay(a delivery.CandidateAnswer, ran map[string]string) delivery.CandidateAnswer { +func asHavesSay(a nodeengine.CandidateAnswer, ran map[string]string) nodeengine.CandidateAnswer { if a.Refused != "" || len(a.Differs) == 0 { return a } @@ -649,7 +652,7 @@ func asHavesSay(a delivery.CandidateAnswer, ran map[string]string) delivery.Cand } a.Differs = kept sort.Strings(a.Same) - a.Verdict = delivery.VerdictOf(a) + a.Verdict = engcomp.VerdictOf(a) return a } @@ -658,7 +661,7 @@ func asHavesSay(a delivery.CandidateAnswer, ran map[string]string) delivery.Cand // of the old form) is given none, so it is folded there again, as the node's own composer folds it, rather than // composed at the trunk's build as if the candidate added it (issue 1126: nats and photos have a trunk build that no // want names). -func shadowBuilds(running []delivery.Composed, placement delivery.Placement, modules map[string]delivery.Module, inOwn []string) map[string]string { +func shadowBuilds(running []engcomp.Composed, placement delivery.Placement, modules map[string]delivery.Module, inOwn []string) map[string]string { fps := map[string]string{} for _, r := range running { if r.Module != "" && r.FP != "" { @@ -679,9 +682,9 @@ func shadowBuilds(running []delivery.Composed, placement delivery.Placement, mod return fps } -func sameAnswer(a, b delivery.CandidateAnswer) bool { - x, errA := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}) - y, errB := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": b}}) +func sameAnswer(a, b nodeengine.CandidateAnswer) bool { + x, errA := json.Marshal(a) + y, errB := json.Marshal(b) return errA == nil && errB == nil && string(x) == string(y) } diff --git a/internal/userlist/userlist.go b/internal/userlist/userlist.go index b818729..b072aac 100644 --- a/internal/userlist/userlist.go +++ b/internal/userlist/userlist.go @@ -31,6 +31,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + engcomp "github.com/novox/mesh-host/internal/compositions" "regexp" "sort" "strings" @@ -55,7 +56,7 @@ type Env struct { Node string // Running is the compositions this node runs now, as its agent holds them (agent.Agent.Everything): where the user // list's file is is read from these alone (issue 1126). Nil runs nothing. - Running func() []delivery.Composed + Running func() []engcomp.Composed // OnDisk is what the resource's file holds on the machine now; nil when there is none. OnDisk func(resource json.RawMessage) ([]byte, error) // Write writes the user list's file resource of a module's composition alone, with body as its content, when it @@ -233,7 +234,7 @@ func (f *Follower) busNode() bool { // look reads what this node runs now for the bus's user list: each module whose composition carries it. func (f *Follower) look() { - var running []delivery.Composed + var running []engcomp.Composed if f.env.Running != nil { running = f.env.Running() } @@ -410,7 +411,7 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) { // A list on the machine that cannot be read is an error: left out, the apply would remove the file as an orphan and the // bus would refuse every connection at its next reload, so the bus module's apply waits instead. A composition without // a user list is c unchanged. -func (f *Follower) Hold(c delivery.Composed) (delivery.Composed, error) { +func (f *Follower) Hold(c engcomp.Composed) (engcomp.Composed, error) { out := c out.Resources = nil for _, raw := range c.Resources { diff --git a/internal/userlist/userlist_test.go b/internal/userlist/userlist_test.go index a891016..5ca5b44 100644 --- a/internal/userlist/userlist_test.go +++ b/internal/userlist/userlist_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "sync" "testing" @@ -59,7 +60,7 @@ func list(users ...string) string { // comp is a composition of module at fp as this node runs it, holding the user list content (none when empty); by // digest when byDigest, as a file over 64 KiB is given; for another node than this one when asked. -func comp(module, fp, content string, p put) delivery.Composed { +func comp(module, fp, content string, p put) engcomp.Composed { server, _ := json.Marshal(map[string]any{"id": module + ".server-conf", "type": "file", "module": module, "path": "/var/lib/nats-module/conf/nats.conf", "content": "include accounts.conf\n"}) rs := []json.RawMessage{server} @@ -73,7 +74,7 @@ func comp(module, fp, content string, p put) delivery.Composed { users, _ := json.Marshal(r) rs = append(rs, users) } - c := delivery.Composed{Node: node, Module: module, FP: fp, Resources: rs} + c := engcomp.Composed{Node: node, Module: module, FP: fp, Resources: rs} if p.asNode != "" { c.Node = p.asNode } @@ -171,7 +172,7 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { } { t.Run(c.name, func(t *testing.T) { var mu sync.Mutex - var running []delivery.Composed + var running []engcomp.Composed var writes []string var reports []Written reported := make(chan struct{}, 64) @@ -226,10 +227,10 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { } } fl := New(Env{Node: node, Grants: gf, - Running: func() []delivery.Composed { + Running: func() []engcomp.Composed { mu.Lock() defer mu.Unlock() - return append([]delivery.Composed(nil), running...) + return append([]engcomp.Composed(nil), running...) }, Kept: func() ([]byte, uint64) { return keptRaw, 9 }, Keep: func([]byte, uint64) {}, @@ -288,7 +289,7 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { } for _, p := range c.after { mu.Lock() - running = []delivery.Composed{comp(p.module, p.fp, p.content, p)} + running = []engcomp.Composed{comp(p.module, p.fp, p.content, p)} mu.Unlock() fl.RunsChanged() } -- 2.54.0 From 7eb7b9fa62d8ef2e609f07a37690961fa790bc25 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:21:48 +0100 Subject: [PATCH 39/73] =?UTF-8?q?Node-engine:=20the=20have,=20the=20node's?= =?UTF-8?q?=20facts,=20the=20record,=20the=20health=20report=20and=20the?= =?UTF-8?q?=20data=20items=20are=20the=20seat's=20contract=20types,=20writ?= =?UTF-8?q?ten=20through=20the=20node-engine=20client;=20the=20interim=20b?= =?UTF-8?q?ridge=20is=20gone=20(Have=20group,=20ADR=200634=20=C2=A71,=20?= =?UTF-8?q?=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/healthowed_test.go | 18 +- cmd/mesh-host/main.go | 2 +- cmd/mesh-host/nodeagent.go | 13 +- cmd/mesh-host/nodeagent_integration_test.go | 57 +++--- cmd/mesh-host/trust.go | 8 +- cmd/mesh-host/whatruns_test.go | 18 +- internal/agent/agent.go | 122 ++++++------ internal/agent/agent_test.go | 108 +++++------ internal/agent/composition.go | 8 +- internal/buckets/buckets.go | 17 ++ internal/buckets/buckets_test.go | 34 ++++ internal/compose/frombus/answer.go | 8 +- internal/compose/frombus/answer_rows_test.go | 11 +- internal/compose/frombus/frombus.go | 26 ++- internal/compose/frombus/frombus_test.go | 27 ++- .../frombus/frombustest/frombustest.go | 13 +- .../frombus/frombustest/frombustest_test.go | 4 +- internal/compose/frombus/held_rows_test.go | 3 +- internal/compose/frombus/states.go | 10 +- internal/judge/aliases.go | 14 +- internal/link/bus.go | 9 +- internal/link/enginedata.go | 7 +- internal/link/have.go | 174 ++++++++---------- internal/link/have_nats_integration_test.go | 20 +- internal/link/have_test.go | 149 +++++++-------- internal/link/health_test.go | 9 +- internal/link/messages.go | 23 ++- internal/link/nodehealth.go | 29 +-- internal/link/record.go | 14 +- internal/link/rotate.go | 9 +- internal/link/rotate_test.go | 4 +- internal/link/session.go | 3 +- internal/network/drill_test.go | 2 +- internal/selfcompose/candidate.go | 6 +- internal/selfcompose/candidate_test.go | 11 +- internal/selfcompose/composedstate_test.go | 9 +- internal/selfcompose/data.go | 28 ++- internal/selfcompose/data_test.go | 23 ++- internal/selfcompose/packages.go | 1 - internal/selfcompose/selfcompose.go | 35 ++-- internal/selfcompose/selfcompose_test.go | 80 ++++---- internal/selfcompose/shadow.go | 22 ++- internal/userlist/userlist.go | 9 +- internal/userlist/userlist_test.go | 2 +- 44 files changed, 650 insertions(+), 549 deletions(-) create mode 100644 internal/buckets/buckets.go create mode 100644 internal/buckets/buckets_test.go diff --git a/cmd/mesh-host/healthowed_test.go b/cmd/mesh-host/healthowed_test.go index a46871c..dcffb5a 100644 --- a/cmd/mesh-host/healthowed_test.go +++ b/cmd/mesh-host/healthowed_test.go @@ -3,15 +3,15 @@ package main import ( "context" "errors" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/liveness" "path/filepath" "strings" "sync" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-host/internal/link" - "github.com/novox/mesh-host/internal/liveness" ) // owedBus is a link that takes reports and statements and keeps a have bucket in memory. @@ -44,10 +44,10 @@ func (b *owedBus) Set(_ context.Context, key string, value []byte, revision uint return nil } -func (b *owedBus) have(key string) (delivery.Have, bool) { +func (b *owedBus) have(key string) (nodeengine.HaveByNodeModuleValue, bool) { b.mu.Lock() defer b.mu.Unlock() - h, err := delivery.DecodeHave(b.kv[key]) + h, err := nodeengineclient.DecodeHave(b.kv[key]) return h, err == nil } @@ -66,19 +66,19 @@ func TestAModuleWithNothingLongRunningIsOkWithinALookOfItsApply(t *testing.T) { } fact := link.ModuleFact{Module: "xorg", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("b", 64)} applying := fact - applying.Phase = string(delivery.PhaseApplying) + applying.Phase = string(nodeengineclient.PhaseApplying) queue.NoteModule(ctx, applying) go judgeWhatRuns(ctx, j, queue, func(string) {}, nil, nil) // The facts look inside the apply, while the module is still applying. queue.SayFacts(ctx, link.Report{Health: &link.Health{Contract: link.LivenessContract, At: time.Now().UTC()}}) - if h, _ := bus.have("n1.xorg"); h.Health != delivery.HealthUnknown { + if h, _ := bus.have("n1.xorg"); h.Health != nodeengineclient.HealthUnknown { t.Fatalf("judged while applying: %+v", h) } queue.NoteModule(ctx, fact) deadline := time.Now().Add(2 * time.Second) // well within one look (liveness.LookEvery, 15 s) for { h, _ := bus.have("n1.xorg") - if h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK { + if h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK { return } if time.Now().After(deadline) { diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index a6d13b3..812222f 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1413,7 +1413,7 @@ func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health h.Network = &link.NetworkHealth{State: ns.State, Since: ns.Since.UTC(), Parts: []link.NetworkPart{}} for _, p := range ns.Parts { h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason, - Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak}) + Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: int64(p.Streak)}) } } return h diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 395d6a9..e44a938 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "os" @@ -86,7 +87,7 @@ func (h sessionHaves) Read(ctx context.Context, module string) (agent.Have, bool if !ok { return agent.Have{}, false } - return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail, + return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail, Sealed: got.SealedAt}, true } @@ -104,24 +105,24 @@ func (h sessionHaves) ReadErr(ctx context.Context, module string) (agent.Have, b if !ok || err != nil { return agent.Have{}, false, err } - return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail, + return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail, Sealed: got.SealedAt}, true, nil } func (h sessionHaves) Write(ctx context.Context, x agent.Have) { if x.Module == agent.NodeModule { // The node's own key holds its facts and the digest of the composition it runs. - if x.Phase == "" || x.Phase == string(delivery.PhaseRunning) { + if x.Phase == "" || x.Phase == string(nodeengineclient.PhaseRunning) { h.session.NoteNode(ctx, x.Composed, x.Sealed) } return } phase := x.Phase - if phase == string(delivery.PhaseRunning) { + if phase == string(nodeengineclient.PhaseRunning) { phase = "" } h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase, - Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits, + Failed: phase == string(nodeengineclient.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits, BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed, PullWait: x.PullWait}) } @@ -653,7 +654,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does - session.NoteComposes(linked, delivery.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere") + session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere") ag = agent.New(env) own.set(ag) current.set(ag) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 1d4d9d3..c7f964b 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" @@ -136,7 +137,7 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed { into *nats.KeyValue history uint8 }{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History}, - {delivery.HaveBucket, &b.haves, delivery.HaveHistory}} { + {buckets.Have, &b.haves, buckets.HaveHistory}} { kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: c.name, History: c.history}) if err != nil { t.Fatal(err) @@ -200,13 +201,13 @@ func (b *itBed) file(module string) string { return string(got) } -func (b *itBed) have(module string) delivery.Have { - key, _ := delivery.HaveKey(itNode, module) +func (b *itBed) have(module string) nodeengine.HaveByNodeModuleValue { + key, _ := nec.HaveKey(itNode, module) e, err := b.haves.Get(key) if err != nil { - return delivery.Have{} + return nodeengine.HaveByNodeModuleValue{} } - h, _ := delivery.DecodeHave(e.Value()) + h, _ := nec.DecodeHave(e.Value()) return h } @@ -293,11 +294,11 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place [] if settled == 0 { t.Fatal("feat-x was never answered") } - hkey, _ := delivery.HaveKey(itNode, "web") + hkey, _ := nec.HaveKey(itNode, "web") for _, detail := range []string{"its tool web_health: no link to the bus is open", "its tool web_health: no answer within 10s"} { h := b.have("web") - h.Health, h.Detail = delivery.HealthFailed, detail - raw, err := delivery.EncodeHave(h) + h.Health, h.Detail = nec.HealthFailed, detail + raw, err := nec.EncodeHave(h) if err != nil { t.Fatal(err) } @@ -334,15 +335,15 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place [] // composes its own. func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValue, hkey string) { t.Helper() - node := func() delivery.HaveNode { + node := func() nodeengine.HaveByNodeNodeValue { e, err := b.haves.Get(hkey) if err != nil { - return delivery.HaveNode{} + return nodeengine.HaveByNodeNodeValue{} } - n, _ := delivery.DecodeHaveNode(e.Value()) + n, _ := nec.DecodeHaveNode(e.Value()) return n } - if n := node(); n.Composes != delivery.ComposesOwn { + if n := node(); n.Composes != nec.ComposesOwn { t.Errorf("a node composing its own says it composes %q", n.Composes) } craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "revert-x"}) @@ -377,10 +378,10 @@ func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValu b.mu.Lock() said := strings.Join(b.said, "\n") b.mu.Unlock() - if n := node(); n.Composes != delivery.ComposesOwn || strings.Contains(said, "applying the controller's compositions") { + if n := node(); n.Composes != nec.ComposesOwn || strings.Contains(said, "applying the controller's compositions") { t.Errorf("without its placement the node turned to the controller's compositions: %q", n.Composes) } - if h := b.have("web"); h.Composed != ran || h.Phase == delivery.PhaseFailed || b.file("web") != "greeting=salut\n" { + if h := b.have("web"); h.Composed != ran || h.Phase == nec.PhaseFailed || b.file("web") != "greeting=salut\n" { t.Errorf("without its placement what runs moved: %+v, %q", h, b.file("web")) } } @@ -422,12 +423,12 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { fp := fpIt("a") modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)}) place, _ := delivery.EncodePlacement(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}}) - rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}}) + rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}}) // it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both. pkey, _ := delivery.PlacementNodeKey(itNode) places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}}) place = places[pkey] - hkey, _ := delivery.HaveNodeKey(itNode) + hkey, _ := nec.HaveNodeKey(itNode) // A provider on it2 serving from its own config value: a mail domain, which it1's composition settles. mailer := `{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` + `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}` @@ -436,9 +437,9 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { mlkey, _ := delivery.ManifestsKey(mdigest) mlmod, _ := delivery.EncodeModule(delivery.Module{FP2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)}) place2, _ := delivery.EncodePlacement(delivery.Placement{Node: "it2", Modules: []delivery.PlacedModule{{Module: "mailer"}}}) - rec2, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}}) + rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}}) pkey2, _ := delivery.LegacyPlacementKey("it2") - hkey2, _ := delivery.HaveNodeKey("it2") + hkey2, _ := nec.HaveNodeKey("it2") buildKey, _ := delivery.BuilderBuildKey("web", fp) done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest, Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}}) @@ -503,7 +504,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str t.Helper() within(t, 2*time.Second, "the have does not name the build", func() bool { h := b.have("web") - return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning + return h.Fp == fp && h.Composed != "" && h.Phase == nec.PhaseRunning }) // ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build // its want names, with the digest its have names (the plan left out of it), and the node's own. @@ -527,7 +528,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str within(t, 3*time.Second, "a changed config value was not composed and applied again", func() bool { return b.file("web") == "greeting=salut\n" }) within(t, 2*time.Second, "the have does not name the revision", func() bool { h := b.have("web") - return h.FP == fp && h.Composed != first && h.Phase == delivery.PhaseRunning + return h.Fp == fp && h.Composed != first && h.Phase == nec.PhaseRunning }) // The node says its facts (its record, as the test wrote it), then a candidate equal to its placement is written: the @@ -551,7 +552,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str if err != nil { return false } - n, err := delivery.DecodeHaveNode(e.Value()) + n, err := nec.DecodeHaveNode(e.Value()) wire, _ := json.Marshal(n.Candidates["feat-x"]) _ = json.Unmarshal(wire, &answer) return err == nil && answer.Proposal == "feat-x" && n.Record != nil @@ -787,13 +788,13 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test t.Fatal(err) } } - rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode, + rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}}) - hkey, _ := delivery.HaveNodeKey(itNode) + hkey, _ := nec.HaveNodeKey(itNode) places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "nats"}}}) // The have the engine wrote of the folded bus: the old-form build, no fingerprint. - natsHave, _ := delivery.HaveKey(itNode, "nats") - oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1792674595610176744", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + natsHave, _ := nec.HaveKey(itNode, "nats") + oldHave, _ := nec.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1792674595610176744", Phase: nec.PhaseRunning, Health: nec.HealthOK}) for key, value := range map[string][]byte{hkey: rec, natsHave: oldHave} { if _, err := b.haves.Put(key, value); err != nil { t.Fatal(err) @@ -848,7 +849,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test within(t, 10*time.Second, "the bus module's first want was not applied", func() bool { h := b.have("nats") got, _ := os.ReadFile(conf) - return h.FP == first && h.Phase == delivery.PhaseRunning && string(got) == "listen: new\n" + return h.Fp == first && h.Phase == nec.PhaseRunning && string(got) == "listen: new\n" }) if moved := runtime.moved(server); len(moved) != 0 || runtime.containers[server] != made { t.Fatalf("the first want moved the bus server its build does not change: %v", moved) @@ -871,10 +872,10 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test busWant(delivery.Want{FP: next, Self: true}) within(t, 10*time.Second, "the build that changes the server was not applied", func() bool { h := b.have("nats") - if h.Phase == delivery.PhaseWaitingYes && h.Waits != "" { + if h.Phase == nec.PhaseWaitingYes && h.Waits != "" { busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits}) } - return h.FP == next && h.Phase == delivery.PhaseRunning + return h.Fp == next && h.Phase == nec.PhaseRunning }) var runs int for _, a := range runtime.moved(server) { diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 7ee9f2a..0f36859 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "os" "path/filepath" "strings" @@ -193,7 +195,7 @@ func placedAllHave(ctx context.Context, session *link.Session, node string) (boo if err != nil { return false, err.Error() } - return allHave(p, func(module string) (delivery.Have, bool, error) { + return allHave(p, func(module string) (nodeengine.HaveByNodeModuleValue, bool, error) { return session.ReadHaveErr(ctx, module) }) } @@ -226,7 +228,7 @@ func placedModules(node string, values map[string][]byte) (delivery.Placement, e // declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to // remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own // apply refused from then on). -func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool, error)) (bool, string) { +func allHave(p delivery.Placement, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) { if len(p.Modules) == 0 { return false, "the seat mesh-placement places no module here" } @@ -240,7 +242,7 @@ func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool, } // A module the mesh provides (mesh-wireguard) never has a fingerprint: the node's own composes it from the manifest // mesh-sdk provides (delivery.ProvidedManifest), never from the kept declaration, so it keeps no read. - if h.Phase != delivery.PhaseRemoved && !strings.HasPrefix(h.FP, delivery.SourcePrefix2) && !delivery.ProvidedByMesh(m.Module) { + if h.Phase != nodeengineclient.PhaseRemoved && !strings.HasPrefix(h.Fp, delivery.SourcePrefix2) && !delivery.ProvidedByMesh(m.Module) { return false, m.Module + " runs at a build of the old form (" + h.Build + "), which only the kept declaration records" } } diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index bfbb992..31e08ff 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -5,6 +5,8 @@ import ( "crypto/ed25519" "encoding/json" "errors" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" engcomp "github.com/novox/mesh-host/internal/compositions" "path/filepath" "sort" @@ -120,24 +122,24 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} // Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the // node's own) keeps the kept declaration read, since it is the one record of what runs of it. - newForm := delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: delivery.PhaseRunning} - oldForm := delivery.Have{Build: "build-1791404191702719760", Phase: delivery.PhaseRunning} + newForm := nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning} + oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning} for _, row := range []struct { name string - has map[string]delivery.Have + has map[string]nodeengine.HaveByNodeModuleValue want bool }{ - {"every placed module has a have of the new form", map[string]delivery.Have{"zsh": newForm, "lemurs": newForm}, true}, - {"one has none yet", map[string]delivery.Have{"zsh": newForm}, false}, - {"one runs at a build of the old form", map[string]delivery.Have{"zsh": newForm, "lemurs": oldForm}, false}, + {"every placed module has a have of the new form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": newForm}, true}, + {"one has none yet", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm}, false}, + {"one runs at a build of the old form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": oldForm}, false}, } { - if got, why := allHave(placed, func(m string) (delivery.Have, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want { + if got, why := allHave(placed, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want { t.Errorf("%s: %v (%s)", row.name, got, why) } } // The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774). withWireguard := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}} - if got, why := allHave(withWireguard, func(m string) (delivery.Have, bool, error) { + if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { if m == overlay.Name { return oldForm, true, nil } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a80bd7b..cda5919 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,6 +17,8 @@ import ( "context" "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "slices" "sort" @@ -61,18 +63,18 @@ type Have struct { // waits for a person's yes (ADR 0547 §3). Judgement string Waits string - // BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, delivery.Have.BuildAlone). + // BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, nodeengine.HaveByNodeModuleValue.BuildAlone). BuildAlone bool // Kept says, in words, what the node keeps of the module as it runs because its manifest does not yet say it (ADR 0601 - // §4, delivery.Have.Kept): the watchman raises a condition from it. + // §4, nodeengine.HaveByNodeModuleValue.Kept): the watchman raises a condition from it. Kept []string // Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by - // slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at + // slot (nodeengine.HaveByNodeModuleValue.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at // its own slot alone (issue 1126, C1). Sealed compositions.Seals // PullWait is, on a failed have, the pull from a store its apply failed on and waits on the holder of (issue 1966 - // ENG-9, delivery.Have.PullWait): read through the have, as node_haves shows it. - PullWait *delivery.PullWait + // ENG-9, nodeengine.HaveByNodeModuleValue.PullWait): read through the have, as node_haves shows it. + PullWait *nodeengine.PullWait } // Haves is the node's have bucket as the agent uses it. @@ -220,7 +222,7 @@ type Agent struct { mu sync.Mutex wants map[string]wantNow // by module comps map[string]compNow // by composed key - running map[string]delivery.Have + running map[string]nodeengine.HaveByNodeModuleValue // applied is the composition each module was last applied from: a revision a want named from the key's history is // not the key's latest value, and what it holds is still held out of the others' applies. applied map[string]compNow @@ -303,7 +305,7 @@ func New(env Env) *Agent { if env.Say == nil { env.Say = func(string) {} } - return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{}, + return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]nodeengine.HaveByNodeModuleValue{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{}, attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{}, pulls: map[string]*pullWait{}, settledAt: map[string]time.Time{}, settledSeq: map[string]int{}, retry: make(chan []string, 8), reconcile: make(chan []string, 64), failedAgain: make(chan string, 8), tries: map[string]int{}, again: make(chan againAsk, 64), @@ -495,7 +497,7 @@ const NotComposedDetail = "the node cannot compose it, and keeps running what it // composerMark says h is a mark NotComposed wrote: failed, its detail opening with NotComposedDetail. func composerMark(h Have) bool { - return h.Phase == string(delivery.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail) + return h.Phase == string(nodeengineclient.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail) } // HavesReadErr is what a Haves may answer besides Read: a have absent (false, nil) told from one that could not be read @@ -590,7 +592,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string a.mu.Unlock() back := mark.prior if ran { - back = Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(delivery.PhaseRunning)} + back = Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(nodeengineclient.PhaseRunning)} if r.Phase != "" { back.Phase = string(r.Phase) } @@ -603,9 +605,9 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string } if !marked { if ran { - mark.prior = Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(r.Phase)} + mark.prior = Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(r.Phase)} if mark.prior.Phase == "" { - mark.prior.Phase = string(delivery.PhaseRunning) + mark.prior.Phase = string(nodeengineclient.PhaseRunning) } } else if h, found, unread := a.readHave(ctx, module); found { mark.prior = h // what its last have said runs: this agent has run nothing of it since its start @@ -624,7 +626,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string // A have that already says another cause (an apply that failed, a backup held, an apply under way) keeps it: a // composer's mark over it would hide that cause, and baseline would take the mark for what runs (review of the // switch, round 1). - if p := mark.prior.Phase; !marked && p != "" && p != string(delivery.PhaseRunning) && !composerMark(mark.prior) { + if p := mark.prior.Phase; !marked && p != "" && p != string(nodeengineclient.PhaseRunning) && !composerMark(mark.prior) { a.mu.Lock() a.sayOnce("unmarked-cause:"+module, fmt.Sprintf("%s is not marked: its have says %s (%s), and the node cannot compose it: %s", module, p, mark.prior.Detail, why)) @@ -639,7 +641,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string a.notComposed[module] = mark a.mu.Unlock() a.writeHave(ctx, Have{Module: module, FP: mark.prior.FP, Composed: mark.prior.Composed, Sealed: mark.prior.Sealed, - Phase: string(delivery.PhaseFailed), Detail: NotComposedDetail + why}) + Phase: string(nodeengineclient.PhaseFailed), Detail: NotComposedDetail + why}) } } @@ -696,7 +698,7 @@ func (a *Agent) KeptNow(ctx context.Context, kept map[string][]string) { a.mu.Unlock() slices.Sort(changed) for _, m := range changed { - if h, ok := a.env.Haves.Read(ctx, m); ok && m != NodeModule && h.Phase != string(delivery.PhaseWaitingYes) { + if h, ok := a.env.Haves.Read(ctx, m); ok && m != NodeModule && h.Phase != string(nodeengineclient.PhaseWaitingYes) { h.Module = m a.writeHave(ctx, h) } @@ -733,10 +735,10 @@ func (a *Agent) Running() []compositions.Composed { var out []compositions.Composed for _, m := range mods { h := a.running[m] - if h.Phase == delivery.PhaseRemoved { + if h.Phase == nodeengineclient.PhaseRemoved { continue } - if c, ok := a.compositionFor(m, h.FP); ok && c.Err == nil && c.Dig == h.Composed { + if c, ok := a.compositionFor(m, h.Fp); ok && c.Err == nil && c.Dig == h.Composed { out = append(out, c.C) } else if c, ok := a.applied[m]; ok && c.Dig == h.Composed { out = append(out, c.C) @@ -890,10 +892,10 @@ func (a *Agent) others(except string) []compositions.Composed { sort.Strings(mods) for _, m := range mods { h := a.running[m] - if h.Phase == delivery.PhaseRemoved { + if h.Phase == nodeengineclient.PhaseRemoved { continue } - if c, ok := a.compositionFor(m, h.FP); ok && c.Err == nil && c.Dig == h.Composed { + if c, ok := a.compositionFor(m, h.Fp); ok && c.Err == nil && c.Dig == h.Composed { out = append(out, c.C) } else if c, ok := a.applied[m]; ok && c.Dig == h.Composed { out = append(out, c.C) @@ -1091,8 +1093,8 @@ func (a *Agent) keepApplied(module string, comp compNow) { // backup, or a composer's mark over what still runs. A have backing up, applying, checking, failed or waiting names a // composition not applied, or not yet (issue 924, review of 031e106). func haveRuns(h Have) bool { - switch delivery.Phase(h.Phase) { - case "", delivery.PhaseRunning, delivery.PhaseHeld: + switch string(h.Phase) { + case "", nodeengineclient.PhaseRunning, nodeengineclient.PhaseHeld: return true } return composerMark(h) @@ -1171,7 +1173,7 @@ func (a *Agent) heldOut(except string) []compositions.Composed { if err != nil || isNode || c.Err != nil || m == except || held[m] { continue } - if h, ok := a.running[m]; ok && h.Phase != delivery.PhaseRemoved { + if h, ok := a.running[m]; ok && h.Phase != nodeengineclient.PhaseRemoved { continue } if w := a.wants[m]; w.Present && w.Want.Removed { @@ -1281,7 +1283,7 @@ func (a *Agent) RetryFailed(module string) error { return fmt.Errorf("%s has no want on this machine, so there is no apply of it to try again", module) case tried && att.Held: return fmt.Errorf("%s is held for a failed backup: the operator's answer releases it", module) - case !tried && ran && r.FP == w.Want.FP && r.Phase != delivery.PhaseRemoved: + case !tried && ran && r.Fp == w.Want.FP && r.Phase != nodeengineclient.PhaseRemoved: return fmt.Errorf("%s runs what its want names, nothing to retry", module) case !tried: return fmt.Errorf("%s has no failed apply to try again: it waits for its composition, or settles on its own", module) @@ -1314,7 +1316,7 @@ func (a *Agent) HoldsNode() bool { a.mu.Lock() defer a.mu.Unlock() h, ok := a.running[NodeModule] - return ok && h.Phase != delivery.PhaseRemoved + return ok && h.Phase != nodeengineclient.PhaseRemoved } func (a *Agent) settleOne(ctx context.Context, module string) { @@ -1370,7 +1372,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) { } a.mu.Lock() r, runs := a.running[module] - runsWant := runs && r.FP == w.Want.FP && r.Composed == w.Want.Composed && r.Phase != delivery.PhaseRemoved + runsWant := runs && r.Fp == w.Want.FP && r.Composed == w.Want.Composed && r.Phase != nodeengineclient.PhaseRemoved att, tried := a.attempt[module] same := tried && att.FP == w.Want.FP && att.Composed == w.Want.Composed if same && att.Held { @@ -1445,7 +1447,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com a.mu.Lock() a.prior[module] = true a.mu.Unlock() - if h.Phase == string(delivery.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != delivery.BackupSkip { + if h.Phase == string(nodeengineclient.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != delivery.BackupSkip { // Held only because this node agent stopped while the backup ran (postgres on novox, 2026-10-31): no // failure and nothing for the operator to answer. It is backed up again and applied (a.prior makes the // apply back it up first); the want still asking a backup is all the answer needed. @@ -1454,7 +1456,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com a.mu.Unlock() return false } - if h.Phase == string(delivery.PhaseHeld) && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip) { + if h.Phase == string(nodeengineclient.PhaseHeld) && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip) { // Held for a failed backup, and the want already carries the operator's answer, written before this engine // started (nats on novox, 2026-11-04: "Back up again" answered, then the engine restarted and took the // answer for the hold's own revision, so it was never acted on; issue 1585). The answer is acted on now: @@ -1467,7 +1469,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com a.mu.Unlock() return false } - if h.Phase == string(delivery.PhaseHeld) { + if h.Phase == string(nodeengineclient.PhaseHeld) { // Held for a failed backup, and the operator's answer is still to come: the hold is kept, nothing is // backed up again or applied, and the next revision of the want is the answer (ADR 0568 §4). Said, so // why it holds is read from the journal and not guessed (issue 1585: this branch was silent). @@ -1478,7 +1480,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com a.attempt[module] = attempt{FP: w.Want.FP, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev} if h.Composed != "" { - a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning} + a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed, Phase: nodeengineclient.PhaseRunning} } a.mu.Unlock() a.runsChanged(module + " was taken as held") @@ -1495,18 +1497,18 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com h.Detail = "" } if h.FP == w.Want.FP && h.Composed == w.Want.Composed && - (h.Phase == "" || h.Phase == string(delivery.PhaseRunning) || marked) { + (h.Phase == "" || h.Phase == string(nodeengineclient.PhaseRunning) || marked) { if a.inactiveAtStart(ctx, module, comp) { return false } a.mu.Lock() - a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning} + a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed, Phase: nodeengineclient.PhaseRunning} a.mu.Unlock() a.runsChanged(module + " was taken from its have") // Said again, so the session holds the module and its key carries the health this engine judges, // not the one an older engine left there (xorg's unknown on g14 and shanks, 2026-10-25). a.writeHave(ctx, Have{Module: module, FP: h.FP, Composed: h.Composed, - Phase: string(delivery.PhaseRunning), Detail: h.Detail, Sealed: h.Sealed}) + Phase: string(nodeengineclient.PhaseRunning), Detail: h.Detail, Sealed: h.Sealed}) return true } return false @@ -1529,10 +1531,10 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com return false } a.mu.Lock() - a.running[module] = delivery.Have{FP: w.Want.FP, Composed: w.Want.Composed, Phase: delivery.PhaseRunning} + a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: w.Want.FP, Composed: w.Want.Composed, Phase: nodeengineclient.PhaseRunning} a.mu.Unlock() a.runsChanged(module + " was taken from the kept declaration") - a.writeHave(ctx, Have{Module: module, FP: w.Want.FP, Composed: w.Want.Composed, Phase: string(delivery.PhaseRunning)}) + a.writeHave(ctx, Have{Module: module, FP: w.Want.FP, Composed: w.Want.Composed, Phase: string(nodeengineclient.PhaseRunning)}) return true } @@ -1567,10 +1569,10 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo a.mu.Unlock() if !runs { if h, found, _ := a.readHave(ctx, module); found { - prev = delivery.Have{FP: h.FP, Composed: h.Composed} + prev = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed} } } - a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseBackingUp)}) + a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseBackingUp)}) if got := a.env.Backup(ctx, module); !got.Done { words := got.Words if got.Interrupted || ctx.Err() != nil { @@ -1585,12 +1587,12 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev} a.mu.Unlock() // It keeps running what it has: the have says what it holds and why it waits. - a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseHeld), + a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseHeld), Detail: words}) return } } - a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseApplying)}) + a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseApplying)}) res := a.env.Apply(ctx, comp.C, others) a.mu.Lock() if !res.Failed || res.Transient { @@ -1607,7 +1609,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo gen := a.gen a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Transient: true, Gen: gen} a.mu.Unlock() - phase, when := delivery.PhaseApplying, "when the bus answers again" + phase, when := nodeengineclient.PhaseApplying, "when the bus answers again" if a.env.Backoff != nil { wait := a.env.Backoff(try) when = "in " + wait.String() @@ -1622,7 +1624,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo }) } if try > transientFor { - phase = delivery.PhaseFailed // still retried, and said as failed: no longer a moment + phase = nodeengineclient.PhaseFailed // still retried, and said as failed: no longer a moment } a.env.Say(fmt.Sprintf("applying %s failed because the bus did not answer (try %d), retried %s: %s", what, try, when, res.Detail)) a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(phase), @@ -1640,7 +1642,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo wait := a.pullWaitOf(module) a.mu.Unlock() a.env.Say(fmt.Sprintf("applying %s failed: %s", what, res.Detail)) - a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: detail, + a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed), Detail: detail, PullWait: wait}) if again != "" { a.askPullAgain(module, again) @@ -1650,14 +1652,14 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo delete(a.pulls, module) delete(a.attempt, module) delete(a.notComposed, module) // the apply says what it runs now - a.running[module] = delivery.Have{FP: fp, Composed: dig, Phase: delivery.PhaseRunning} + a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: dig, Phase: nodeengineclient.PhaseRunning} a.applied[module] = comp a.mu.Unlock() a.keepApplied(module, comp) if a.env.Settled != nil { a.env.Settled(module) } - a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseRunning), Detail: res.Detail}) + a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseRunning), Detail: res.Detail}) } func (a *Agent) remove(ctx context.Context, module string, w wantNow) { @@ -1668,7 +1670,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) { a.mu.Unlock() return } - if ran && r.Phase == delivery.PhaseRemoved { + if ran && r.Phase == nodeengineclient.PhaseRemoved { a.mu.Unlock() return } @@ -1685,7 +1687,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) { delete(a.attempt, module) delete(a.pulls, module) delete(a.had, module) - a.running[module] = delivery.Have{Phase: delivery.PhaseRemoved} + a.running[module] = nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRemoved} a.mu.Unlock() if a.env.Applied != nil { if err := a.env.Applied.Drop(module); err != nil { @@ -1766,17 +1768,17 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp a.readKeptData(ctx, module) // the manifest may read now though it did not at the start a.mu.Lock() r, ran := a.running[module] - if ran && r.Composed == comp.Dig && r.Phase != delivery.PhaseRemoved { + if ran && r.Composed == comp.Dig && r.Phase != nodeengineclient.PhaseRemoved { a.mu.Unlock() // Composed back to what runs: a have still waiting for a yes for a move since gone says running again, or it waits // for a yes nobody needs (issue 774, 2026-10-31: four providers stayed waiting-yes once their consumers composed). - if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(delivery.PhaseWaitingYes) { + if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(nodeengineclient.PhaseWaitingYes) { // Its sealed values only when they are of what runs; else writeHave reads them (sealedOf). var sealed compositions.Seals if h.Composed == r.Composed { sealed = h.Sealed } - a.writeHave(ctx, Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(delivery.PhaseRunning), Sealed: sealed}) + a.writeHave(ctx, Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(nodeengineclient.PhaseRunning), Sealed: sealed}) } return false } @@ -1799,12 +1801,12 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp case known: j = judge.JudgeMove(&from, comp.C) case prior: - j = judge.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"} + j = judge.Judged{Judgement: nodeengineclient.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"} default: j = judge.JudgeMove(nil, comp.C) } switch j.Judgement { - case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot: + case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot: default: return false } @@ -1812,7 +1814,7 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp a.sayOnce("yes:"+module+":"+comp.Dig, fmt.Sprintf("%s at %s waits for a person's yes: %s", module, w.Want.FP, j.Words)) prev := a.running[module] a.mu.Unlock() - a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseWaitingYes), + a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseWaitingYes), Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.FP)}) return true } @@ -1820,17 +1822,17 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp // buildAlone says a move of module from what runs to the build want changes the build alone (ADR 0601 §1): another build than // the one it runs, and the build it runs composed with today's settings and secrets is the composition it runs. Anything it // cannot tell is no: the move is then asked of a person. -func (a *Agent) buildAlone(ctx context.Context, module string, prev delivery.Have, want string) bool { - if prev.FP == "" && prev.Composed == "" { +func (a *Agent) buildAlone(ctx context.Context, module string, prev nodeengine.HaveByNodeModuleValue, want string) bool { + if prev.Fp == "" && prev.Composed == "" { // Nothing of it run since this agent's start: its last have says what runs, as the have written keeps it. if h, ok := a.env.Haves.Read(ctx, module); ok { - prev.FP, prev.Composed = h.FP, h.Composed + prev.Fp, prev.Composed = h.FP, h.Composed } } - if a.env.ComposedToday == nil || prev.FP == "" || prev.Composed == "" || prev.FP == want { + if a.env.ComposedToday == nil || prev.Fp == "" || prev.Composed == "" || prev.Fp == want { return false } - dig, ok := a.env.ComposedToday(ctx, module, prev.FP) + dig, ok := a.env.ComposedToday(ctx, module, prev.Fp) return ok && dig == prev.Composed } @@ -2001,11 +2003,15 @@ func (a *Agent) Settled(holder string) { // pullWaitOf is the pull a failed apply of module waits on, as its have carries it (ENG-9): the image, the seat and its // holder, when the pull began, and the last retry with what triggered it; nil when it waits on no holder. a.mu is held. -func (a *Agent) pullWaitOf(module string) *delivery.PullWait { +func (a *Agent) pullWaitOf(module string) *nodeengine.PullWait { pw := a.pulls[module] if pw == nil { return nil } - return &delivery.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last, - RetriedAt: pw.lastAt} + out := &nodeengine.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last} + if !pw.lastAt.IsZero() { + at := pw.lastAt + out.RetriedAt = &at + } + return out } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index c34699a..2d205b4 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "maps" "slices" @@ -300,7 +302,7 @@ func TestAWantAndItsCompositionApplyTheModuleAndTheHaveNamesBoth(t *testing.T) { eventually(t, "the module was not applied", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "the have does not name both", func() bool { h := b.haves.last("web") - return h.FP == fp && h.Composed == dig && h.Phase == string(delivery.PhaseRunning) + return h.FP == fp && h.Composed == dig && h.Phase == string(nodeengineclient.PhaseRunning) }) // Settled is told once what runs holds the module, so a declaration read then names its files (the filling's Knock; // ace, 2026-10-25 21:17). @@ -337,7 +339,7 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) { stop := b.run() defer stop() eventually(t, "the have was not written", func() bool { return b.haves.last("web").Composed == dig }) - if h := b.haves.last("web"); h.FP != fp || h.Phase != string(delivery.PhaseRunning) { + if h := b.haves.last("web"); h.FP != fp || h.Phase != string(nodeengineclient.PhaseRunning) { t.Fatalf("the have: %+v", h) } time.Sleep(50 * time.Millisecond) @@ -357,7 +359,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) { fp := fpOf("a") ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1")) wk, wraw := wantFor(t, "avahi", fp, dig, "") - b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), + b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed), Detail: "apply: unit failed to start"}} b.comps.before(ck, craw) b.wants.before(wk, wraw) @@ -372,7 +374,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) { fp := fpOf("a") ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1")) wk, wraw := wantFor(t, "avahi", fp, dig, "") - b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), + b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed), Detail: NotComposedDetail + "frombus: the credentials of ace: context canceled"}} b.comps.before(ck, craw) b.wants.before(wk, wraw) @@ -380,7 +382,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) { defer stop() eventually(t, "the mark was not cleared", func() bool { h := b.haves.last("avahi") - return h.Phase == string(delivery.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == "" + return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == "" }) if got := b.appliedList(); len(got) != 0 { t.Fatalf("a module whose mark names what the want names was applied: %v", got) @@ -427,7 +429,7 @@ func testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines(t *testing if len(log) != 1 { t.Fatalf("the have was said %d times, want once: %+v", len(log), log) } - if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(delivery.PhaseRunning) || h.Detail != "kept" { + if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(nodeengineclient.PhaseRunning) || h.Detail != "kept" { t.Fatalf("the have said again: %+v", h) } if got := b.appliedList(); len(got) != 0 { @@ -454,7 +456,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "the have does not say failed", func() bool { h := b.haves.last("web") - return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp + return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp }) // An unrelated key changes: not a retry. ck9, craw9, _ := composition(t, "web", fpOf("9"), false, file("web", "x", "9")) @@ -472,7 +474,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { t.Fatalf("a failed apply was refused a retry: %v", err) } eventually(t, "the asked retry did not apply", func() bool { return len(b.appliedList()) == 2 }) - eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) + eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) }) for module, words := range map[string]string{"web": "runs what its want names", "ghost": "has no want"} { if err := b.ag.RetryFailed(module); err == nil || !strings.Contains(err.Error(), words) { t.Errorf("a retry of %s: %v, want refused saying %q", module, err, words) @@ -509,7 +511,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "the have does not say whom it waits on", func() bool { h := b.haves.last("web") - return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled") + return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled") }) b.idle() if n := len(b.appliedList()); n != 1 { @@ -527,7 +529,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { eventually(t, "not tried again on the holder's settled", func() bool { return len(b.appliedList()) == 2 }) eventually(t, "the have does not say what triggered the retry", func() bool { h := b.haves.last("web") - return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled") + return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled") }) b.idle() if n := len(b.appliedList()); n != 2 { @@ -536,7 +538,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { // The failed have carries the pull it waits on (ENG-9, read through node_haves): the image, whose settled it waits // on, and what triggered the last retry. if w := b.haves.last("web").PullWait; w == nil || w.Holder != "distribution" || w.Image != "reg/x@sha256:1" || - w.Began.IsZero() || !strings.Contains(w.Retried, "distribution settled") || w.RetriedAt.IsZero() { + w.Began.IsZero() || !strings.Contains(w.Retried, "distribution settled") || w.RetriedAt == nil { t.Fatalf("the failed have says it waits on %+v", w) } // The next settled answers once more, and the pull works: the module runs, and waits on nothing. @@ -546,7 +548,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { tick() b.ag.Settled("distribution") eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 }) - eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) + eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) }) if w := b.haves.last("web").PullWait; w != nil { t.Fatalf("a running module's have still waits on %+v", w) } @@ -620,11 +622,11 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) { b.ag.RetryTransient() } eventually(t, "not applied again without a new revision", func() bool { return len(b.appliedList()) == 2 }) - eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(delivery.PhaseRunning) }) + eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(nodeengineclient.PhaseRunning) }) b.haves.mu.Lock() defer b.haves.mu.Unlock() for _, h := range b.haves.log { - if h.Phase == string(delivery.PhaseFailed) { + if h.Phase == string(nodeengineclient.PhaseFailed) { t.Errorf("a transient failure was written failed: %+v", h) } } @@ -664,7 +666,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { }) eventually(t, "the have does not say held", func() bool { h := b.haves.last("db") - return h.Phase == string(delivery.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1 + return h.Phase == string(nodeengineclient.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1 }) if got := b.appliedList(); len(got) != 0 { t.Fatalf("something was applied behind a failed backup: %v", got) @@ -703,7 +705,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { } eventually(t, "not running at the new fingerprint", func() bool { h := b.haves.last("db") - return h.Phase == string(delivery.PhaseRunning) && h.FP == fp2 && h.Composed == dig2 + return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp2 && h.Composed == dig2 }) // The node agent stopped while the backup ran, the module known only from its have: the held have names what runs @@ -713,7 +715,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { ib.comps.before(ck2, craw2) iwk, iwraw := wantFor(t, "db", fp2, dig2, delivery.BackupAgain) ib.wants.before(iwk, iwraw) - ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseRunning)}} + ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseRunning)}} began := make(chan struct{}) ib.env.Backup = func(ctx context.Context, _ string) Backup { close(began) @@ -723,7 +725,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { stopI := ib.run() <-began stopI() - if h := ib.haves.last("db"); h.Phase != string(delivery.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted { + if h := ib.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted { t.Errorf("held after an interrupted backup: %+v", h) } @@ -733,24 +735,24 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { for _, row := range []struct { detail, backup string backups, applied int - phase delivery.Phase + phase string namesNothing bool // the have names no fp and no composition }{ - {BackupInterrupted, delivery.BackupAgain, 1, 1, delivery.PhaseRunning, false}, + {BackupInterrupted, delivery.BackupAgain, 1, 1, nodeengineclient.PhaseRunning, false}, // novox's postgres: its held have, written before mesh-host #189, names nothing it runs. - {BackupInterrupted, "", 1, 1, delivery.PhaseRunning, true}, + {BackupInterrupted, "", 1, 1, nodeengineclient.PhaseRunning, true}, // A backup that failed, and the want carries the operator's answer, written before this start (nats on novox, // 2026-11-04, issue 1585): again backs it up and applies, skip applies without one; no answer keeps the hold. - {"restic: the repository is locked", delivery.BackupAgain, 1, 1, delivery.PhaseRunning, false}, - {"restic: the repository is locked", delivery.BackupSkip, 0, 1, delivery.PhaseRunning, false}, - {"restic: the repository is locked", "", 0, 0, delivery.PhaseHeld, false}, + {"restic: the repository is locked", delivery.BackupAgain, 1, 1, nodeengineclient.PhaseRunning, false}, + {"restic: the repository is locked", delivery.BackupSkip, 0, 1, nodeengineclient.PhaseRunning, false}, + {"restic: the repository is locked", "", 0, 0, nodeengineclient.PhaseHeld, false}, } { r := newBed(t) r.comps.before(ck1, craw1) r.comps.before(ck2, craw2) wk, wraw := wantFor(t, "db", fp2, dig2, row.backup) r.wants.before(wk, wraw) - held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseHeld), Detail: row.detail} + held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseHeld), Detail: row.detail} if row.namesNothing { held.FP, held.Composed = "", "" } @@ -800,7 +802,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { return raw } r.wants.before(wk, selfWant(row.atStart)) - r.haves.stored = map[string]Have{"nats": {Module: "nats", FP: fp2, Composed: dig, Phase: string(delivery.PhaseHeld), + r.haves.stored = map[string]Have{"nats": {Module: "nats", FP: fp2, Composed: dig, Phase: string(nodeengineclient.PhaseHeld), Detail: "the backup failed: sh: exit status 1"}} stopR := r.run() defer stopR() @@ -831,7 +833,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { eventually(t, "the answer was not acted on", func() bool { return len(r.appliedList()) == 1 }) eventually(t, "not running after the answer", func() bool { h := r.haves.last("nats") - return h.Phase == string(delivery.PhaseRunning) && h.Composed == dig + return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == dig }) r.mu.Lock() n := len(r.backups) @@ -959,9 +961,9 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { if got := a.Everything(); len(got) != 2 { t.Fatalf("before the agent holds anything: %d compositions, want the 2 kept", len(got)) } - a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: delivery.PhaseRunning} + a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: nodeengineclient.PhaseRunning} ck, craw, dig := composition(t, "web", fpOf("a"), false, file("web", "x", "new")) - a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: dig, Phase: delivery.PhaseRunning} + a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: dig, Phase: nodeengineclient.PhaseRunning} a.takeComposition(Update{Key: ck, Value: craw, Revision: 1}) got := a.Everything() if len(got) != 2 { @@ -998,7 +1000,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { webDig, _ := compositions.DigestOf(web) store.kept[NodeModule], store.kept["web"] = ownRaw, webRaw b.haves.Write(context.Background(), Have{Module: NodeModule, Composed: ownDig}) - b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(delivery.PhaseRunning)}) + b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(nodeengineclient.PhaseRunning)}) // The composer's first pass reads what runs when its watch begins, before the agent reads any have. firstCh := make(chan []compositions.Composed, 1) comps := b.comps @@ -1143,7 +1145,7 @@ func TestAFailedApplyIsAppliedAgainAfterARestartWhateverIsKept(t *testing.T) { b.comps.before(ck, craw) b.wants.before(wk, wraw) b.kept = map[string][]json.RawMessage{"web": {file("web", "x", "1")}} - b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed)}} + b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed)}} stop := b.run() defer stop() eventually(t, "a failed apply was marked running from the kept declaration", func() bool { return len(b.appliedList()) == 1 }) @@ -1425,7 +1427,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) { {name: "a have naming the bucket's value", have: &Have{Module: "power", FP: fp, Composed: appliedDig}, bucket: appliedRaw, filled: old + "," + added, told: "the haves were read at the start", data: true}, {name: "a have applying names nothing that runs yet", have: &Have{Module: "power", FP: fp, Composed: appliedDig, - Phase: string(delivery.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"}, + Phase: string(nodeengineclient.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"}, {name: "a data module from the kept declaration says Data as its manifest does", filled: old, told: "the haves were read at the start", data: true, holdsData: func(context.Context, string) (bool, bool) { return true, true }}, @@ -1583,7 +1585,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // names what runs; composed again, the have is what it runs. web was applied by this agent; idle was not since the // agent's start (the switch and every delivery of the node-engine restart it), so what runs is what its have said. idleFP, idleDig := fpOf("c"), "sha256:"+strings.Repeat("9", 64) - b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(delivery.PhaseRunning), + b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(nodeengineclient.PhaseRunning), Sealed: compositions.Seals{"idle.key#sealed": `{"box":"idle"}`}}) want("idle", idleFP, "") eventually(t, "idle's want was not taken", func() bool { @@ -1599,7 +1601,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( module, fp, composed, slot, sealed string }{{"web", fp, dig, "web.pw#sealed", "1"}, {"idle", idleFP, idleDig, "idle.key#sealed", `{"box":"idle"}`}} { h := b.haves.last(row.module) - if h.Phase != string(delivery.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed || + if h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed || len(h.Sealed) != 1 || h.Sealed[row.slot] != row.sealed || !strings.Contains(h.Detail, "web's password") { t.Errorf("%s's have while the node cannot compose: %+v", row.module, h) } @@ -1614,7 +1616,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( want("fresh", fpOf("e"), "") // never ran, its have absent: marked, naming nothing it runs // A have that already says another cause keeps it (review of the switch, round 1): an apply that failed. brokenFP, brokenDig := fpOf("f"), "sha256:"+strings.Repeat("8", 64) - b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(delivery.PhaseFailed), + b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(nodeengineclient.PhaseFailed), Detail: "apply: unit failed to start"}) want("broken", brokenFP, "") eventually(t, "unread's want was not taken", func() bool { @@ -1626,7 +1628,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( if h := b.haves.last("unread"); h.Module != "" { t.Errorf("a module whose have could not be read was marked: %+v", h) } - if h := b.haves.last("fresh"); h.Phase != string(delivery.PhaseFailed) || h.FP != "" { + if h := b.haves.last("fresh"); h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != "" { t.Errorf("a module that never ran, its have absent, is not marked: %+v", h) } if h := b.haves.last("broken"); h.Detail != "apply: unit failed to start" || h.Composed != brokenDig { @@ -1642,7 +1644,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( } b.ag.NotComposed(context.Background(), "") for _, row := range []struct{ module, composed string }{{"web", dig}, {"idle", idleDig}} { - if h := b.haves.last(row.module); h.Phase != string(delivery.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 { + if h := b.haves.last(row.module); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 { t.Errorf("%s's have once the node composes again: %+v", row.module, h) } } @@ -1650,14 +1652,14 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // novox) is marked alone, naming its own words; web, which composes, keeps running, unmarked. Composed again, idle's // have is what it runs. b.ag.NotComposedEach(context.Background(), "", map[string]string{"idle": "idle says ${config:name}, and nothing sets it"}) - if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") { + if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") { t.Errorf("idle's have while it alone is held: %+v", h) } - if h := b.haves.last("web"); h.Phase != string(delivery.PhaseRunning) || h.Composed != dig { + if h := b.haves.last("web"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != dig { t.Errorf("web, which composes, is marked beside idle: %+v", h) } b.ag.NotComposedEach(context.Background(), "", nil) - if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseRunning) || h.Composed != idleDig { + if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != idleDig { t.Errorf("idle's have once it composes again: %+v", h) } // A revision at the same build (a config value changed) is applied. @@ -1682,7 +1684,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( b.comps.put(dk, drawMoved) eventually(t, "db's revision did not wait for a yes", func() bool { h := b.haves.last("db") - return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == ddigMoved + return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == ddigMoved }) if h := b.haves.last("db"); h.BuildAlone { t.Errorf("a revision at the build it runs is said to change the build alone: %+v", h) @@ -1692,8 +1694,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( keep(dk, draw) keep(dk, drawMoved) view, err := b.ag.Composition(context.Background(), "db", "", true) - if err != nil || view.Have == nil || view.Have.Phase != string(delivery.PhaseWaitingYes) || view.Composition.Digest != ddigMoved || - view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(delivery.JudgedRecreates) || + if err != nil || view.Have == nil || view.Have.Phase != string(nodeengineclient.PhaseWaitingYes) || view.Composition.Digest != ddigMoved || + view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(nodeengineclient.JudgedRecreates) || !view.Judged.WaitsForYes || len(view.Diff) != 1 || view.Diff[0].ID != "db.server" || view.Diff[0].Change != "changed" || len(view.Revisions) != 2 { t.Errorf("the composition of db waiting for a yes reads %+v (%v)", view, err) @@ -1731,7 +1733,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( b.comps.put(dk, draw) eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool { h := b.haves.last("db") - return h.Phase == string(delivery.PhaseRunning) && h.Composed == ddig && h.Waits == "" + return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == ddig && h.Waits == "" }) if got := len(b.appliedList()); got != 4 { t.Fatalf("a composition equal to what runs was applied again: %d applies", got) @@ -1742,7 +1744,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( // node-engine restart it): its have alone says what runs. Its move waits for a yes as db's does, and its have keeps // what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3). recFP, recDig := fpOf("d"), "sha256:"+strings.Repeat("8", 64) - b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(delivery.PhaseRunning), + b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(nodeengineclient.PhaseRunning), Sealed: compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}}) rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64))) b.comps.put(dk2, draw2) @@ -1757,7 +1759,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( }{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} { eventually(t, row.module+"'s move did not wait for a yes", func() bool { h := b.haves.last(row.module) - return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == row.waits + return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == row.waits }) h := b.haves.last(row.module) if h.FP != row.fp || (row.composed != "" && h.Composed != row.composed) || h.Composed == "" || @@ -1775,8 +1777,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( want("rec", fpB, rdig) eventually(t, "the yes did not apply the move", func() bool { h, r := b.haves.last("db"), b.haves.last("rec") - return len(b.appliedList()) == applied+2 && h.Phase == string(delivery.PhaseRunning) && h.FP == fpB && - r.Phase == string(delivery.PhaseRunning) && r.FP == fpB && r.Composed == rdig + return len(b.appliedList()) == applied+2 && h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fpB && + r.Phase == string(nodeengineclient.PhaseRunning) && r.FP == fpB && r.Composed == rdig }) } @@ -1816,7 +1818,7 @@ func testAnEngineStartReconcilesAModuleFromTheMachinesFacts(t *testing.T) { } if c.held { b.haves.stored = map[string]Have{"web": {Module: "web", FP: fpOf("0"), Composed: "sha256:" + strings.Repeat("0", 64), - Phase: string(delivery.PhaseHeld), Detail: "restic: the repository is locked"}} + Phase: string(nodeengineclient.PhaseHeld), Detail: "restic: the repository is locked"}} } if c.kept { b.kept = map[string][]json.RawMessage{"web": rs} @@ -1867,7 +1869,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone( stop := b.run() defer stop() eventually(t, "web was not settled", func() bool { return b.haves.last("web").Composed == digW }) - eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(delivery.PhaseHeld) }) + eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(nodeengineclient.PhaseHeld) }) if got := b.appliedList(); len(got) != 0 { t.Fatalf("applied at the start: %v", got) } @@ -1882,13 +1884,13 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone( if got := b.appliedList(); len(got) != 0 { t.Fatalf("a held module and an unwanted one were applied: %v", got) } - if h := b.haves.last("db"); h.Phase != string(delivery.PhaseHeld) { + if h := b.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) { t.Fatalf("the held module's have changed: %+v", h) } b.ag.Reconcile("web") eventually(t, "web was not applied again", func() bool { return len(b.appliedList()) == 1 }) - eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) + eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) }) b.mu.Lock() b.failWith = "systemctl start web.service: exit status 1" @@ -1896,7 +1898,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone( b.ag.Reconcile("web") eventually(t, "the failed re-apply did not write the have failed with its words", func() bool { h := b.haves.last("web") - return h.Phase == string(delivery.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1" + return h.Phase == string(nodeengineclient.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1" }) time.Sleep(50 * time.Millisecond) if got := b.appliedList(); len(got) != 2 { diff --git a/internal/agent/composition.go b/internal/agent/composition.go index a3f86f4..48e3bd2 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -4,11 +4,11 @@ import ( "context" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" engcomp "github.com/novox/mesh-host/internal/compositions" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/judge" ) @@ -147,7 +147,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo have = &h phase := h.Phase if phase == "" { - phase = string(delivery.PhaseRunning) + phase = string(nodeengineclient.PhaseRunning) } view.Have = &HaveView{FP: h.FP, Composed: h.Composed, Phase: phase, Detail: h.Detail, Judgement: h.Judgement, Waits: h.Waits} } @@ -224,7 +224,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo running = &from rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP} if ran { - rv.FP, rv.Composed, rv.Phase = r.FP, r.Composed, string(r.Phase) + rv.FP, rv.Composed, rv.Phase = r.Fp, r.Composed, string(r.Phase) } view.Runs = rv } @@ -243,7 +243,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words} if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) { switch j.Judgement { - case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot: + case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot: view.Judged.WaitsForYes = a.env.Policy(ctx, module) == catalogue.PolicyRecord } } diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go new file mode 100644 index 0000000..ae8c8ac --- /dev/null +++ b/internal/buckets/buckets.go @@ -0,0 +1,17 @@ +// Package buckets holds the names of the bus buckets the node-engine reads and writes, and how many revisions it keeps of +// each. A contract carries the local name of a state, not the module that holds it (ADR 0625 §4, §7); the node-engine +// reads through grants composed in code, so the holder's name is kept here, in its own code (ADR 0634 §1, finding F4). +package buckets + +// The node-engine's own states and the have state, written by every node's engine for its own keys. +const ( + // Have holds a module's have on a node, `.`, and the node's own facts, `.node`. + Have = "node-engine_have" + // Data holds a module's data items on a node. + Data = "mesh-host_data" + // Health holds a node's own health report. + Health = "mesh-host_health" +) + +// HaveHistory is the number of revisions the have bucket keeps per key. +const HaveHistory = 20 diff --git a/internal/buckets/buckets_test.go b/internal/buckets/buckets_test.go new file mode 100644 index 0000000..db981d6 --- /dev/null +++ b/internal/buckets/buckets_test.go @@ -0,0 +1,34 @@ +package buckets + +import ( + "encoding/json" + "os" + "testing" +) + +// The engine's own states are named _ by the manifest; a constant that drifts from it reads and writes a +// bucket nobody else knows (ADR 0634 §1, finding F4: the holder's name is kept in code and held to the manifest by this row). +func TestTheOwnStatesAreTheManifestsStates(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Module string `json:"module"` + State []struct { + Name string `json:"name"` + } `json:"state"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + declared := map[string]bool{} + for _, s := range m.State { + declared[m.Module+"_"+s.Name] = true + } + for name, bucket := range map[string]string{"data": Data, "health": Health} { + if !declared[bucket] { + t.Errorf("the bucket %s for the state %s is not one the manifest of %s declares (%v)", bucket, name, m.Module, declared) + } + } +} diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index be62825..a3b9093 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "maps" @@ -13,7 +14,6 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" ) @@ -81,7 +81,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st if len(failed) == 0 { return refuse(err) } - failed[delivery.HaveNodeWord] = err.Error() + failed[nodeengineclient.NodeWord] = err.Error() composed, skipped, stood = nil, nil, nil } // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want @@ -97,7 +97,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st } } } - if _, whole := failed[delivery.HaveNodeWord]; whole { + if _, whole := failed[nodeengineclient.NodeWord]; whole { // Nothing composed to compare: every refusal is said, and nothing else. for _, m := range sortedKeys(failed) { a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]}) @@ -173,7 +173,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st } // another node's addition: the module here that carries it differs, which the assigner judges case module == "" || node != s.Node: // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's. - refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x) + refusals[nodeengineclient.NodeWord] = append(refusals[nodeengineclient.NodeWord], x) default: refusals[module] = append(refusals[module], x) } diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 134e3ae..784624e 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" engcomp "github.com/novox/mesh-host/internal/compositions" "os" @@ -42,7 +43,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) } b := &bus{placements: map[string]delivery.Placement{"n1": p}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, + records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, builds: map[string]*delivery.BuilderBuild{}} @@ -88,11 +89,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want) } } - var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves - if wire, err := json.Marshal(a); err != nil || json.Unmarshal(wire, &wireAnswer) != nil { - t.Fatalf("the answer does not read back: %v", err) - } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": wireAnswer}}); err != nil { + if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil { t.Errorf("the answer is not one a have holds: %v (%s)", err, raw) } if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK { @@ -594,7 +591,7 @@ func otherNodeNotResolvedIsNamed(t *testing.T) { b := &bus{placements: map[string]delivery.Placement{ "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, + records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} given := map[string]*delivery.Placement{} diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 4e6cb09..557d842 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -35,6 +35,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "net/netip" @@ -57,8 +58,8 @@ type Reader interface { // Placements is every node's placement, by node (delivery.PlacementBucket). Placements(ctx context.Context) (map[string]delivery.Placement, error) // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of - // delivery.HaveBucket (ADR 0555 §3). - Records(ctx context.Context) (map[string]delivery.NodeRecord, error) + // buckets.Have (ADR 0555 §3). + Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error) // Modules is every module the indexer holds, by module (delivery.ModulesBucket). Modules(ctx context.Context) (map[string]delivery.Module, error) // Manifest is the raw manifest of a digest (delivery.ManifestsBucket). @@ -252,7 +253,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module type base struct { in compose.Input placements map[string]delivery.Placement - records map[string]delivery.NodeRecord + records map[string]nodeengine.NodeRecord modules map[string]delivery.Module // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order // read: every scope holds it. @@ -987,15 +988,24 @@ func declaresOwnSecrets(in compose.Input, node string) bool { return false } +// attachedOf is a record's attached networks as the composer holds them (the same two fields). +func attachedOf(in []nodeengine.AttachedNetwork) []catalogue.AttachedNetwork { + var out []catalogue.AttachedNetwork + for _, n := range in { + out = append(out, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network}) + } + return out +} + // nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person // decided of it, and where its modules run). -func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { +func nodeOf(rec nodeengine.NodeRecord, p delivery.Placement) compose.Node { n := compose.Node{ Name: rec.Node, Place: compose.Place{Key: rec.TunnelKey}, SealingKey: rec.SealingKey, - OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, - ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, + OutwardLinks: rec.OutwardLinks, Networks: attachedOf(rec.Networks), NetworksSaid: rec.NetworksSaid, + ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: int(rec.Contract), Stated: rec.Contract > 0, } placeNode(&n, p) if len(rec.Capabilities) > 0 { @@ -1092,7 +1102,7 @@ func tunnelOf(t delivery.RecordTunnel) compose.Tunnel { // carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key // where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers). -func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer { +func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]nodeengine.NodeRecord) []compose.CarriedPeer { byKey := map[string]string{} for node, rec := range records { if rec.TunnelKey != "" { @@ -1116,7 +1126,7 @@ func carriedPeers(t compose.Tunnel, names map[string]string, records map[string] } // nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. -func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string { +func nodesOf(placements map[string]delivery.Placement, records map[string]nodeengine.NodeRecord) []string { seen := map[string]bool{} for n := range placements { seen[n] = true diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 78f1a46..efd0e77 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "github.com/novox/mesh-host/internal/compositions" "os" @@ -31,7 +32,7 @@ type bus struct { vaultAuthority string handed map[string]bool placements map[string]delivery.Placement - records map[string]delivery.NodeRecord + records map[string]nodeengine.NodeRecord modules map[string]delivery.Module manifests map[string][]byte values map[string]map[string]any // by node.module @@ -81,10 +82,12 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { return b.placements, nil } -func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } -func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } -func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } -func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } +func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } +func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } +func (b *bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { + return b.records, nil +} +func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { raw, ok := b.manifests[digest] if !ok { @@ -180,7 +183,7 @@ func TestInput(t *testing.T) { {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, }, - records: map[string]delivery.NodeRecord{ + records: map[string]nodeengine.NodeRecord{ "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, }, @@ -331,7 +334,7 @@ func TestInput(t *testing.T) { p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) } return &bus{placements: map[string]delivery.Placement{"n1": p}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, + records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, @@ -490,11 +493,7 @@ func TestInput(t *testing.T) { if a.Verdict != c.verdict || !c.check(a) { t.Errorf("%s: %+v", name, a) } - var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves - if wire, err := json.Marshal(a); err != nil || json.Unmarshal(wire, &wireAnswer) != nil { - t.Fatalf("%s: the answer does not read back: %v", name, err) - } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": wireAnswer}}); err != nil { + if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil { t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) } } @@ -844,9 +843,9 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { } records, _ := b.Records(ctx) for node, r := range records { - k, _ := delivery.HaveNodeKey(node) + k, _ := nodeengineclient.HaveNodeKey(node) rec := r - raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) + raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec}) put(HaveRead, k, raw, err) } modules, _ := b.Modules(ctx) diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go index 5b00ed6..432e05a 100644 --- a/internal/compose/frombus/frombustest/frombustest.go +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -12,6 +12,7 @@ import ( "embed" "encoding/json" "errors" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "strings" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -25,7 +26,7 @@ var testdata embed.FS // Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set. type Bus struct { Placement map[string]delivery.Placement - Record map[string]delivery.NodeRecord + Record map[string]nodeengine.NodeRecord Module map[string]delivery.Module Manifests map[string][]byte Value map[string]map[string]any // by node.module @@ -41,9 +42,11 @@ var _ frombus.Reader = (*Bus)(nil) func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) { return b.Placement, nil } -func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil } -func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil } -func (b *Bus) VaultAuthority(context.Context) string { return "" } +func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { + return b.Record, nil +} +func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil } +func (b *Bus) VaultAuthority(context.Context) string { return "" } func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) { raw, ok := b.Manifests[digest] @@ -152,7 +155,7 @@ func Provisioned() (*Bus, error) { Placement: map[string]delivery.Placement{Node: {Node: Node, Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, - Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", + Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, Module: map[string]delivery.Module{}, Manifests: map[string][]byte{}, Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}, diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index 9598824..8769825 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -3,6 +3,8 @@ package frombustest import ( "context" "encoding/json" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" engcomp "github.com/novox/mesh-host/internal/compositions" "reflect" "slices" @@ -313,7 +315,7 @@ func statesOf(t *testing.T, b *Bus) frombus.States { put(frombus.PlacementRead, "placement."+node, raw, err) } for node, rec := range b.Record { - raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) + raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec}) put(frombus.HaveRead, node+".node", raw, err) } for module, m := range b.Module { diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index 0aa2fe2..8a083b0 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "testing" @@ -18,7 +19,7 @@ import ( func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { t.Helper() p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} - b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, + b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} fps := map[string]string{} for i, raw := range manifests { diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index ca932f3..0c2a163 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -12,6 +12,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "strings" "time" @@ -84,11 +86,11 @@ func (r statesReader) Placements(context.Context) (map[string]delivery.Placement return keys.All(), err } -func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) { - out := map[string]delivery.NodeRecord{} +func (r statesReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { + out := map[string]nodeengine.NodeRecord{} err := r.each(HaveRead, func(k string, v []byte) { - if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { - if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil { + if node, _, isNode, err := nodeengineclient.ParseHaveKey(k); err == nil && isNode { + if h, err := nodeengineclient.DecodeHaveNode(v); err == nil && h.Record != nil { out[node] = *h.Record } } diff --git a/internal/judge/aliases.go b/internal/judge/aliases.go index bf0f30e..2ad8d2e 100644 --- a/internal/judge/aliases.go +++ b/internal/judge/aliases.go @@ -5,24 +5,24 @@ package judge import ( + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-sdk/go/delivery" engcomp "github.com/novox/mesh-host/internal/compositions" ) // The shapes of mesh-sdk's delivery package this package judges, by the names its code was written with. type ( Composed = engcomp.Composed - Judgement = delivery.Judgement + Judgement = string CandidateAnswer = nodeengine.CandidateAnswer ModuleDiffers = nodeengine.CandidateDiffers ResourceBodies = nodeengine.CandidateBodies ) const ( - JudgedRecreates = delivery.JudgedRecreates - JudgedMovesData = delivery.JudgedMovesData - JudgedNeither = delivery.JudgedNeither - JudgedCannot = delivery.JudgedCannot - JudgedUnchanged = delivery.JudgedUnchanged + JudgedRecreates = nodeengineclient.JudgedRecreates + JudgedMovesData = nodeengineclient.JudgedMovesData + JudgedNeither = "neither" + JudgedCannot = nodeengineclient.JudgedCannot + JudgedUnchanged = "unchanged" ) diff --git a/internal/link/bus.go b/internal/link/bus.go index 0670097..6fff2ab 100644 --- a/internal/link/bus.go +++ b/internal/link/bus.go @@ -4,10 +4,9 @@ import ( "context" "errors" "fmt" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" + "github.com/novox/mesh-host/internal/buckets" + "time" ) // Bus is the link open now as the session holds it. What it can do is asked of it by what it implements (HaveBus, @@ -87,9 +86,9 @@ func (b OverNATS) HaveBind(ctx context.Context) (HaveBucket, error) { } js = bounded } - kv, err := js.KeyValue(delivery.HaveBucket) + kv, err := js.KeyValue(buckets.Have) if err != nil { - return nil, fmt.Errorf("the have bucket %s: %w", delivery.HaveBucket, err) + return nil, fmt.Errorf("the have bucket %s: %w", buckets.Have, err) } return natsHaveBucket{kv}, nil } diff --git a/internal/link/enginedata.go b/internal/link/enginedata.go index 1880b4a..4a155d7 100644 --- a/internal/link/enginedata.go +++ b/internal/link/enginedata.go @@ -4,17 +4,16 @@ import ( "context" "errors" "fmt" - - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" + "github.com/novox/mesh-host/internal/buckets" ) // ---- the data items of the modules placed on this node, in the bus's own storage (novox/hq ADR 0577 clause 1) ------ -// DataBucket is the node-engine's declared state data (delivery.EngineDataBucket), created by the grants part from the +// DataBucket is the node-engine's declared state data (buckets.Data), created by the grants part from the // engine's manifest as its states acts and health are: one key `.` per module placed on the node that // declares data items, written by that node's engine alone, read by that node's disk-load. -const DataBucket = delivery.EngineDataBucket +const DataBucket = buckets.Data // dataStore binds DataBucket over the link open now: bound, never created. func (l *natsLink) dataStore() (nats.KeyValue, error) { diff --git a/internal/link/have.go b/internal/link/have.go index 656de22..0047e6f 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "github.com/novox/mesh-host/internal/compositions" @@ -12,8 +13,6 @@ import ( "strings" "sync" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // ErrHaveAbsent is what HaveGet returns for a key the bucket does not hold. @@ -40,14 +39,14 @@ type HaveBucket interface { type haveState struct { mu sync.Mutex modules map[string]ModuleFact - health map[string]delivery.Health + health map[string]string // words is, by module, what the last look found of the resource that made its health failed or waits: the have says // it, so a failed health is never without words (issue 1496: "the node gave no words" hid a directory used as found). words map[string]string // looked says a look's health was taken: a running module it judges no resource of then has nothing that could be // down, and is ok (issue 1945). looked bool - node *delivery.HaveNode + node *nodeengine.HaveByNodeNodeValue // nodeComposed is the digest of the node's own composition it runs (ADR 0539 §9), kept beside the node's facts. nodeComposed string // nodeSealed is, on a node composing its own, each value its own composition carries sealed to its key (ADR 0547 §3, @@ -65,7 +64,7 @@ type haveState struct { removed map[string]string // userList is what the bus node's engine last wrote of the bus's user list, by the module whose have says it (novox/hq // ADR 0547 §8); kept apart from the module's fact, which every apply replaces. - userList map[string]delivery.UserListWritten + userList map[string]nodeengine.UserListWritten // userListNoKey is each module whose user list was said to have no have key to ride on, so it is said once. userListNoKey map[string]bool // unjudged is each module whose composition moved (an apply began, or the fact names another build) and that no look @@ -76,8 +75,8 @@ type haveState struct { // inMotion says a module's phase is one of a change still under way: what a look finds of it then judges nothing of the // composition being applied (a container stopped to be replaced reads unhealthy), so its health is not yet judged. func inMotion(phase string) bool { - switch delivery.Phase(phase) { - case delivery.PhaseBackingUp, delivery.PhaseApplying, delivery.PhaseChecking: + switch string(phase) { + case nodeengineclient.PhaseBackingUp, nodeengineclient.PhaseApplying, nodeengineclient.PhaseChecking: return true } return false @@ -85,15 +84,15 @@ func inMotion(phase string) bool { // healthOf is the health a module's have carries: unknown (not yet judged) while its change is under way or until a look // after it, the last look's otherwise. The caller holds the lock. -func (s *haveState) healthOf(module string, f ModuleFact) delivery.Health { +func (s *haveState) healthOf(module string, f ModuleFact) string { if inMotion(f.Phase) { - return delivery.HealthUnknown + return nodeengineclient.HealthUnknown } if got, known := s.health[module]; known { return got } if s.unjudged[module] { - return delivery.HealthUnknown + return nodeengineclient.HealthUnknown } return nothingJudged(f, s.looked) } @@ -141,8 +140,8 @@ func (s *haveState) takeLook(h *Health) { // wordsOf is what a have says of its health when the apply said nothing: the words of the resource that made it failed // or waits, while it is so. The caller holds the lock. -func (s *haveState) wordsOf(module string, health delivery.Health) string { - if health != delivery.HealthFailed && health != delivery.HealthWaits { +func (s *haveState) wordsOf(module string, health string) string { + if health != nodeengineclient.HealthFailed && health != nodeengineclient.HealthWaits { return "" } return s.words[module] @@ -154,8 +153,8 @@ func (s *haveState) wordsOf(module string, health delivery.Health) string { // module's health: it says nothing of how the module runs, waits for the operator's hand-over and keeps its own // condition, raised from the same statement (issue 1496: on ace mesh-build-node's cache directory, there before the // mesh, made its have failed while its check answered healthy, and the assigner held its version on every node). -func moduleHealths(h *Health) (map[string]delivery.Health, map[string]string) { - out, words := map[string]delivery.Health{}, map[string]string{} +func moduleHealths(h *Health) (map[string]string, map[string]string) { + out, words := map[string]string{}, map[string]string{} if h == nil { return out, words } @@ -183,7 +182,7 @@ func moduleHealths(h *Health) (map[string]delivery.Health, map[string]string) { } } for module, r := range rank { - out[module] = [...]delivery.Health{delivery.HealthOK, delivery.HealthUnknown, delivery.HealthWaits, delivery.HealthFailed}[r] + out[module] = [...]string{nodeengineclient.HealthOK, nodeengineclient.HealthUnknown, nodeengineclient.HealthWaits, nodeengineclient.HealthFailed}[r] if r < 2 { delete(words, module) } @@ -206,21 +205,28 @@ func resourceWords(r ResourceHealth) string { } // nodeFacts is the node's own facts of a report, each as the JSON the report carries it in. -func nodeFacts(r Report) delivery.HaveNode { - n := delivery.HaveNode{V: sdkkeys.Version} - carry := func(into *json.RawMessage, empty bool, v any) { +func nodeFacts(r Report) nodeengine.HaveByNodeNodeValue { + n := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version} + carry := func(into *[]map[string]any, empty bool, v any) { if empty { return } - if raw, err := json.Marshal(v); err == nil { - *into = raw + raw, err := json.Marshal(v) + if err != nil { + return + } + var list []map[string]any + if json.Unmarshal(raw, &list) == nil { + *into = list } } carry(&n.Windows, len(r.Windows) == 0, r.Windows) carry(&n.Busy, len(r.Busy) == 0, r.Busy) carry(&n.Strays, len(r.Strays) == 0, r.Strays) carry(&n.Filters, len(r.Filters) == 0, r.Filters) - carry(&n.Profile, len(r.Profile) == 0, r.Profile) + if len(r.Profile) > 0 { + n.Profile = r.Profile + } return n } @@ -296,7 +302,7 @@ func (q *Session) noteApply(r Report) bool { // nodeFactsWithRecord is the node's own facts of a report with its record (ADR 0555 §3): a report that says nothing of // the machine leaves the record last made standing, and one without a health statement keeps the contract last stated. -func (q *Session) nodeFactsWithRecord(r Report, before *delivery.HaveNode) *delivery.HaveNode { +func (q *Session) nodeFactsWithRecord(r Report, before *nodeengine.HaveByNodeNodeValue) *nodeengine.HaveByNodeNodeValue { n := nodeFacts(r) if q.Keys != nil { keys := q.Keys() @@ -377,20 +383,20 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { sort.Strings(modules) for _, module := range modules { f := s.modules[module] - h := delivery.Have{V: sdkkeys.Version, FP: f.FP, Composed: f.Composed, Build: f.Build, - Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown, Detail: f.Detail} + h := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Fp: f.FP, Composed: f.Composed, Build: f.Build, + Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown, Detail: f.Detail} h.Kept = f.Kept - if delivery.Phase(f.Phase) == delivery.PhaseWaitingYes { - h.Judgement, h.Waits, h.BuildAlone = delivery.Judgement(f.Judgement), f.Waits, f.BuildAlone + if string(f.Phase) == nodeengineclient.PhaseWaitingYes { + h.Judgement, h.Waits, h.BuildAlone = string(f.Judgement), f.Waits, f.BuildAlone } switch { case f.Phase != "": - h.Phase = delivery.Phase(f.Phase) + h.Phase = string(f.Phase) case f.Failed: - h.Phase = delivery.PhaseFailed + h.Phase = nodeengineclient.PhaseFailed } h.SealedAt = f.Sealed - if h.Phase == delivery.PhaseFailed { + if h.Phase == nodeengineclient.PhaseFailed { h.PullWait = f.PullWait } h.Health = s.healthOf(module, f) @@ -400,19 +406,19 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { if w, ok := s.userList[module]; ok { h.UserList = &w } - key, err := delivery.HaveKey(node, module) + key, err := nodeengineclient.HaveKey(node, module) if err != nil { q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err)) continue } - raw, err := delivery.EncodeHave(h) + raw, err := nodeengineclient.EncodeHave(h) if err != nil { q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err)) continue } if !q.putHave(write, bucket, key, raw, func(current []byte) bool { - c, err := delivery.DecodeHave(current) - return err == nil && delivery.SameHave(c, h) + c, err := nodeengineclient.DecodeHave(current) + return err == nil && nodeengineclient.SameHave(c, h) }) { return } @@ -428,19 +434,19 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { return } if s.node != nil { - key, err := delivery.HaveNodeKey(node) + key, err := nodeengineclient.HaveNodeKey(node) if err == nil { var raw []byte facts := *s.node facts.Composed = s.nodeComposed facts.SealedAt = s.nodeSealed - facts.Candidates = interimAnswers(s.nodeCandidates) + facts.Candidates = s.nodeCandidates facts.Composes, facts.ComposesWhy = s.nodeComposes, s.nodeComposesWhy - if raw, err = delivery.EncodeHaveNode(facts); err == nil { + if raw, err = nodeengineclient.EncodeHaveNode(facts); err == nil { want := facts if !q.putHave(write, bucket, key, raw, func(current []byte) bool { - c, err := delivery.DecodeHaveNode(current) - return err == nil && delivery.SameHaveNode(c, want) + c, err := nodeengineclient.DecodeHaveNode(current) + return err == nil && nodeengineclient.SameHaveNode(c, want) }) { return } @@ -503,15 +509,15 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b } sort.Strings(gone) for _, module := range gone { - key, err := delivery.HaveKey(node, module) + key, err := nodeengineclient.HaveKey(node, module) if err != nil { q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err)) delete(s.removed, module) continue } - h := delivery.Have{V: sdkkeys.Version, Build: s.removed[module], Phase: delivery.PhaseRemoved, - Health: delivery.HealthUnknown, Detail: "the applied declaration no longer holds the module"} - raw, err := delivery.EncodeHave(h) + h := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: s.removed[module], Phase: nodeengineclient.PhaseRemoved, + Health: nodeengineclient.HealthUnknown, Detail: "the applied declaration no longer holds the module"} + raw, err := nodeengineclient.EncodeHave(h) if err != nil { q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err)) delete(s.removed, module) @@ -531,7 +537,7 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b q.haveFailed(err) return false } - if c, err := delivery.DecodeHave(current); err == nil && c.Phase == delivery.PhaseRemoved { + if c, err := nodeengineclient.DecodeHave(current); err == nil && c.Phase == nodeengineclient.PhaseRemoved { delete(s.removed, module) continue } @@ -565,7 +571,7 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st } sort.Strings(alone) for _, module := range alone { - key, err := delivery.HaveKey(node, module) + key, err := nodeengineclient.HaveKey(node, module) if err != nil { q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err)) continue @@ -592,7 +598,7 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st return false } delete(s.userListNoKey, module) - h, err := delivery.DecodeHave(current) + h, err := nodeengineclient.DecodeHave(current) if err != nil { q.sayHave(fmt.Sprintf("the bus's user list is not written in %s: its value cannot be read: %v", key, err)) continue @@ -600,10 +606,10 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st w := s.userList[module] next := h next.UserList = &w - if delivery.SameHave(h, next) { + if nodeengineclient.SameHave(h, next) { continue } - raw, err := delivery.EncodeHave(next) + raw, err := nodeengineclient.EncodeHave(next) if err != nil { q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err)) continue @@ -685,12 +691,12 @@ func (q *Session) NoteModule(ctx context.Context, f ModuleFact) { // NoteUserList takes what the bus node's engine wrote of the bus's user list, and writes it in the bus module's have // where it differs from the bucket's (novox/hq ADR 0547 §8). A module this engine has said no have of yet carries it // from its first have on. -func (q *Session) NoteUserList(ctx context.Context, module string, w delivery.UserListWritten) { +func (q *Session) NoteUserList(ctx context.Context, module string, w nodeengine.UserListWritten) { q.init() s := &q.haves s.mu.Lock() if s.userList == nil { - s.userList = map[string]delivery.UserListWritten{} + s.userList = map[string]nodeengine.UserListWritten{} } s.userList[module] = w s.mu.Unlock() @@ -732,7 +738,7 @@ func (q *Session) NoteNode(ctx context.Context, composed string, sealed composit s.nodeComposed = composed s.nodeSealed = sealed if s.node == nil { - n := delivery.HaveNode{V: sdkkeys.Version} + n := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version} s.node = &n } s.mu.Unlock() @@ -765,7 +771,7 @@ func (q *Session) NoteCandidates(ctx context.Context, answers map[string]nodeeng q.writeHaves(ctx, bus) } -// NoteComposes says, in the node's own key, whose compositions the node applies (delivery.ComposesOwn) and why: written +// NoteComposes says, in the node's own key, whose compositions the node applies (nodeengineclient.ComposesOwn) and why: written // at each start of its agent, where every have is read (issue 1126). func (q *Session) NoteComposes(ctx context.Context, composes, why string) { q.init() @@ -786,49 +792,49 @@ func (q *Session) NoteComposes(ctx context.Context, composes, why string) { // ReadNodeHave is the node's own key as the bucket holds it, and whether it holds one: what the node's own composition // it last ran is and carries sealed, read after a restart (ADR 0547 §3). -func (q *Session) ReadNodeHave(ctx context.Context) (delivery.HaveNode, bool) { +func (q *Session) ReadNodeHave(ctx context.Context) (nodeengine.HaveByNodeNodeValue, bool) { n, ok, _ := q.ReadNodeHaveErr(ctx) return n, ok } // ReadNodeHaveErr is ReadNodeHave telling the key absent (false, nil) from one that could not be read (false, the // error): a bus that did not answer is never kept as a node that ran nothing (issue 1126, 2026-10-28). -func (q *Session) ReadNodeHaveErr(ctx context.Context) (delivery.HaveNode, bool, error) { +func (q *Session) ReadNodeHaveErr(ctx context.Context) (nodeengine.HaveByNodeNodeValue, bool, error) { q.init() q.mu.Lock() bus := q.bus q.mu.Unlock() hb, ok := bus.(HaveBus) if !ok || bus == nil { - return delivery.HaveNode{}, false, ErrNotLinkedHaves + return nodeengine.HaveByNodeNodeValue{}, false, ErrNotLinkedHaves } - key, err := delivery.HaveNodeKey(q.Membership.Node) + key, err := nodeengineclient.HaveNodeKey(q.Membership.Node) if err != nil { - return delivery.HaveNode{}, false, err + return nodeengine.HaveByNodeNodeValue{}, false, err } read, cancel := context.WithTimeout(ctx, q.timeout()) defer cancel() bucket, err := hb.HaveBind(read) if err != nil { - return delivery.HaveNode{}, false, err + return nodeengine.HaveByNodeNodeValue{}, false, err } value, _, err := bucket.Get(read, key) if errors.Is(err, ErrHaveAbsent) { - return delivery.HaveNode{}, false, nil + return nodeengine.HaveByNodeNodeValue{}, false, nil } if err != nil { - return delivery.HaveNode{}, false, err + return nodeengine.HaveByNodeNodeValue{}, false, err } - n, err := delivery.DecodeHaveNode(value) + n, err := nodeengineclient.DecodeHaveNode(value) if err != nil { - return delivery.HaveNode{}, false, nil // a key no engine can read names nothing it runs + return nodeengine.HaveByNodeNodeValue{}, false, nil // a key no engine can read names nothing it runs } return n, true, nil } // ReadHave is the have this node-engine last wrote for a module, and whether the bucket holds one. A bucket that cannot // be read answers as none: the agent then compares with the declaration it kept, and applies only what differs. -func (q *Session) ReadHave(ctx context.Context, module string) (delivery.Have, bool) { +func (q *Session) ReadHave(ctx context.Context, module string) (nodeengine.HaveByNodeModuleValue, bool) { h, ok, _ := q.ReadHaveErr(ctx, module) return h, ok } @@ -838,62 +844,44 @@ var ErrNotLinkedHaves = errors.New("no link to the bus that binds the have bucke // ReadHaveErr is ReadHave telling a key absent (false, nil) from one that could not be read (false, the error): a bus // that does not answer for a moment is not a module that never ran (issue 1126). -func (q *Session) ReadHaveErr(ctx context.Context, module string) (delivery.Have, bool, error) { +func (q *Session) ReadHaveErr(ctx context.Context, module string) (nodeengine.HaveByNodeModuleValue, bool, error) { q.init() q.mu.Lock() bus := q.bus q.mu.Unlock() hb, ok := bus.(HaveBus) if !ok || bus == nil { - return delivery.Have{}, false, ErrNotLinkedHaves + return nodeengine.HaveByNodeModuleValue{}, false, ErrNotLinkedHaves } - key, err := delivery.HaveKey(q.Membership.Node, module) + key, err := nodeengineclient.HaveKey(q.Membership.Node, module) if err != nil { - return delivery.Have{}, false, err + return nodeengine.HaveByNodeModuleValue{}, false, err } read, cancel := context.WithTimeout(ctx, q.timeout()) defer cancel() bucket, err := hb.HaveBind(read) if err != nil { - return delivery.Have{}, false, err + return nodeengine.HaveByNodeModuleValue{}, false, err } value, _, err := bucket.Get(read, key) if errors.Is(err, ErrHaveAbsent) { - return delivery.Have{}, false, nil + return nodeengine.HaveByNodeModuleValue{}, false, nil } if err != nil { - return delivery.Have{}, false, err + return nodeengine.HaveByNodeModuleValue{}, false, err } - h, err := delivery.DecodeHave(value) + h, err := nodeengineclient.DecodeHave(value) if err != nil { - return delivery.Have{}, false, nil // a have no engine can read names nothing it runs + return nodeengine.HaveByNodeModuleValue{}, false, nil // a have no engine can read names nothing it runs } return h, true, nil } // nothingJudged is the health of a module a look judged no resource of: ok when it was looked at and the module applied // and runs (nothing of it could be down), unknown otherwise. -func nothingJudged(f ModuleFact, looked bool) delivery.Health { - if looked && !f.Failed && (f.Phase == "" || f.Phase == string(delivery.PhaseRunning)) { - return delivery.HealthOK +func nothingJudged(f ModuleFact, looked bool) string { + if looked && !f.Failed && (f.Phase == "" || f.Phase == string(nodeengineclient.PhaseRunning)) { + return nodeengineclient.HealthOK } - return delivery.HealthUnknown -} - -// interimAnswers is the node's candidate answers in the form mesh-sdk's HaveNode still holds them, until the node-engine's -// have is written through the seat's contract types (the Have group of the go/delivery rewrite): the two shapes share -// their wire names, so the JSON carries one into the other. -func interimAnswers(in map[string]nodeengine.CandidateAnswer) map[string]delivery.CandidateAnswer { - if in == nil { - return nil - } - raw, err := json.Marshal(in) - if err != nil { - return nil - } - var out map[string]delivery.CandidateAnswer - if json.Unmarshal(raw, &out) != nil { - return nil - } - return out + return nodeengineclient.HealthUnknown } diff --git a/internal/link/have_nats_integration_test.go b/internal/link/have_nats_integration_test.go index eaa862c..eb862c8 100644 --- a/internal/link/have_nats_integration_test.go +++ b/internal/link/have_nats_integration_test.go @@ -4,12 +4,12 @@ package link import ( "context" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "github.com/nats-io/nats.go" + "github.com/novox/mesh-host/internal/buckets" "sync/atomic" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/nats-io/nats.go" ) // 1132/AC8: against a real bus with the bucket node-engine_have, a node-engine that applies a declaration of two @@ -21,9 +21,9 @@ import ( func TestNatsHavesAreWrittenOnceAndOnlyOnChange(t *testing.T) { conn, js := aBus(t) const node = "haveok" - kv, err := js.KeyValue(delivery.HaveBucket) + kv, err := js.KeyValue(buckets.Have) if err != nil { - if kv, err = js.CreateKeyValue(&nats.KeyValueConfig{Bucket: delivery.HaveBucket, History: delivery.HaveHistory}); err != nil { + if kv, err = js.CreateKeyValue(&nats.KeyValueConfig{Bucket: buckets.Have, History: buckets.HaveHistory}); err != nil { t.Fatal(err) } } @@ -66,17 +66,17 @@ func TestNatsHavesAreWrittenOnceAndOnlyOnChange(t *testing.T) { first := revisions() a, _ := kv.Get(node + ".a") - haveA, err := delivery.DecodeHave(a.Value()) - if err != nil || haveA.Phase != delivery.PhaseRunning || haveA.Build != "b-1" || haveA.Health != delivery.HealthOK { + haveA, err := nodeengineclient.DecodeHave(a.Value()) + if err != nil || haveA.Phase != nodeengineclient.PhaseRunning || haveA.Build != "b-1" || haveA.Health != nodeengineclient.HealthOK { t.Fatalf("a holds %+v (%v)", haveA, err) } b, _ := kv.Get(node + ".b") - haveB, err := delivery.DecodeHave(b.Value()) - if err != nil || haveB.Phase != delivery.PhaseFailed || haveB.Detail == "" { + haveB, err := nodeengineclient.DecodeHave(b.Value()) + if err != nil || haveB.Phase != nodeengineclient.PhaseFailed || haveB.Detail == "" { t.Fatalf("b holds %+v (%v)", haveB, err) } n, _ := kv.Get(node + ".node") - if hn, err := delivery.DecodeHaveNode(n.Value()); err != nil || len(hn.Windows) == 0 { + if hn, err := nodeengineclient.DecodeHaveNode(n.Value()); err != nil || len(hn.Windows) == 0 { t.Fatalf("the node key holds %+v (%v)", hn, err) } diff --git a/internal/link/have_test.go b/internal/link/have_test.go index af02cab..79a8b94 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "github.com/novox/mesh-host/internal/compositions" @@ -15,7 +16,6 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" ) @@ -104,11 +104,11 @@ func (l *haveLink) Set(_ context.Context, key string, value []byte, revision uin return nil } -func (l *haveLink) have(t *testing.T, key string) delivery.Have { +func (l *haveLink) have(t *testing.T, key string) nodeengine.HaveByNodeModuleValue { t.Helper() l.hmu.Lock() defer l.hmu.Unlock() - h, err := delivery.DecodeHave(l.kv[key].value) + h, err := nodeengineclient.DecodeHave(l.kv[key].value) if err != nil { t.Fatalf("%s holds no have: %v", key, err) } @@ -121,9 +121,9 @@ func (l *haveLink) setCount() int { return len(l.sets) } -func (l *haveLink) put(t *testing.T, key string, h delivery.Have) { +func (l *haveLink) put(t *testing.T, key string, h nodeengine.HaveByNodeModuleValue) { t.Helper() - raw, err := delivery.EncodeHave(h) + raw, err := nodeengineclient.EncodeHave(h) if err != nil { t.Fatal(err) } @@ -165,11 +165,11 @@ func TestAnAppliedModuleIsWrittenRunningAndAFailedOneFailed(t *testing.T) { q, _ := aHaveQueue(l) q.SayFacts(context.Background(), appliedModules()) a, b := l.have(t, "n1.a"), l.have(t, "n1.b") - if a.Phase != delivery.PhaseRunning || a.Build != "b-1" || a.Health != delivery.HealthOK { + if a.Phase != nodeengineclient.PhaseRunning || a.Build != "b-1" || a.Health != nodeengineclient.HealthOK { t.Errorf("a: %+v", a) } - if b.Phase != delivery.PhaseFailed || b.Build != "b-2" || !strings.Contains(b.Detail, "no space") || - b.Health != delivery.HealthFailed { + if b.Phase != nodeengineclient.PhaseFailed || b.Build != "b-2" || !strings.Contains(b.Detail, "no space") || + b.Health != nodeengineclient.HealthFailed { t.Errorf("b: %+v", b) } if l.kv["n1.a"].rev != 1 { @@ -180,14 +180,14 @@ func TestAnAppliedModuleIsWrittenRunningAndAFailedOneFailed(t *testing.T) { // 1132/AC5: a have equal to the bucket's value is not written; a different one is written over the last revision. func TestAHaveIsWrittenOnlyWhenItDiffers(t *testing.T) { l := newHaveLink() - l.put(t, "n1.a", delivery.Have{V: sdkkeys.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - l.put(t, "n1.b", delivery.Have{V: sdkkeys.Version, Build: "b-0", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.a", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + l.put(t, "n1.b", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-0", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) q, _ := aHaveQueue(l) q.SayFacts(context.Background(), appliedModules()) if l.kv["n1.a"].rev != 1 { t.Errorf("a equal to the bucket was written again, to revision %d", l.kv["n1.a"].rev) } - if l.kv["n1.b"].rev != 2 || l.have(t, "n1.b").Phase != delivery.PhaseFailed { + if l.kv["n1.b"].rev != 2 || l.have(t, "n1.b").Phase != nodeengineclient.PhaseFailed { t.Errorf("b was not updated over its last revision: %+v at %d", l.have(t, "n1.b"), l.kv["n1.b"].rev) } before := l.setCount() @@ -205,7 +205,7 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) { {Module: "c", Build: "b"}, {Module: "d", Build: "b"}, {Module: "e", Build: "b"}}} q.SayFacts(context.Background(), rep) for _, m := range []string{"a", "b", "c", "d", "e"} { - if h := l.have(t, "n1."+m).Health; h != delivery.HealthUnknown { + if h := l.have(t, "n1."+m).Health; h != nodeengineclient.HealthUnknown { t.Errorf("%s with no look is %s, want unknown", m, h) } } @@ -220,8 +220,8 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) { if !q.SayHealth(context.Background(), *h) { t.Fatal("not said") } - for m, want := range map[string]delivery.Health{"a": delivery.HealthWaits, "b": delivery.HealthFailed, - "c": delivery.HealthWaits, "d": delivery.HealthUnknown, "e": delivery.HealthOK} { + for m, want := range map[string]string{"a": nodeengineclient.HealthWaits, "b": nodeengineclient.HealthFailed, + "c": nodeengineclient.HealthWaits, "d": nodeengineclient.HealthUnknown, "e": nodeengineclient.HealthOK} { if got := l.have(t, "n1."+m).Health; got != want { t.Errorf("%s is %s, want %s", m, got, want) } @@ -239,7 +239,7 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) { } h2.Resources = []ResourceHealth{{Module: "a", Resource: "a.server", State: StateHealthy}} q.SayHealth(context.Background(), h2) - if got := l.have(t, "n1.a"); got.Health != delivery.HealthOK || got.Detail != "" { + if got := l.have(t, "n1.a"); got.Health != nodeengineclient.HealthOK || got.Detail != "" { t.Errorf("a after a healthy look is %s, saying %q", got.Health, got.Detail) } } @@ -255,14 +255,19 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { rep.Filters = []Filter{{Where: "inet mesh", Owner: "mesh"}} rep.Profile = map[string]any{"cores": 8} q.SayFacts(context.Background(), rep) - got, err := delivery.DecodeHaveNode(l.kv["n1.node"].value) + got, err := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value) if err != nil { t.Fatal(err) } - enc := func(v any) json.RawMessage { b, _ := json.Marshal(v); return b } - want := delivery.HaveNode{V: sdkkeys.Version, Windows: enc(rep.Windows), Busy: enc(rep.Busy), Strays: enc(rep.Strays), - Filters: enc(rep.Filters), Profile: enc(rep.Profile)} - if !delivery.SameHaveNode(got, want) { + enc := func(v any) []map[string]any { + var out []map[string]any + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &out) + return out + } + want := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version, Windows: enc(rep.Windows), Busy: enc(rep.Busy), Strays: enc(rep.Strays), + Filters: enc(rep.Filters), Profile: rep.Profile} + if !nodeengineclient.SameHaveNode(got, want) { t.Errorf("the node key holds %+v, want %+v", got, want) } @@ -279,14 +284,14 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { rep.Outward = []string{"eth0"} rep.Health = &Health{Contract: NodeKeyContract} q.SayFacts(context.Background(), rep) - if got, err = delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || got.Record == nil { + if got, err = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); err != nil || got.Record == nil { t.Fatalf("the node key holds no record: %+v, %v", got, err) } - wantRecord := delivery.NodeRecord{V: sdkkeys.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", + wantRecord := nodeengine.NodeRecord{V: sdkkeys.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "bus-state-calls", "ledger-calls", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, - Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract, + Networks: []nodeengine.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract, ManifestReads: catalogue.ManifestReads()} // what it reads of a manifest: its contract's (ADR 0614 §4) - if !delivery.SameNodeRecord(*got.Record, wantRecord) { + if !nodeengineclient.SameNodeRecord(*got.Record, wantRecord) { t.Errorf("record = %+v, want %+v", *got.Record, wantRecord) } // Among them, that it fills a seat's reach from the seat holder's listen (ADR 0632 §1). @@ -307,7 +312,7 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { } l.statedErr, l.stated = nil, map[string]string{} q.SayFacts(context.Background(), rep) - if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || !slices.Equal(got.Record.Capabilities, []string{"apt", "systemd"}) { + if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || !slices.Equal(got.Record.Capabilities, []string{"apt", "systemd"}) { t.Errorf("a runner stating no calls: revision %d (was %d), capabilities %v", l.kv["n1.node"].rev, at, got.Record.Capabilities) } at++ @@ -316,13 +321,13 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { at++ keys.Certificate = "cert-2" q.SayFacts(context.Background(), rep) - if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || got.Record.Certificate != "cert-2" { + if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || got.Record.Certificate != "cert-2" { t.Errorf("a new certificate: revision %d (was %d), record %+v", l.kv["n1.node"].rev, at, got.Record) } bare := rep bare.Health = nil q.SayFacts(context.Background(), bare) - if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); got.Record.Contract != NodeKeyContract { + if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); got.Record.Contract != NodeKeyContract { t.Errorf("a report without a health statement dropped the contract: %+v", got.Record) } } @@ -342,7 +347,7 @@ func TestAFailedHaveWriteIsSaidAndWrittenAgain(t *testing.T) { l.failing = false l.hmu.Unlock() q.SayFacts(context.Background(), appliedModules()) - if l.have(t, "n1.a").Phase != delivery.PhaseRunning { + if l.have(t, "n1.a").Phase != nodeengineclient.PhaseRunning { t.Error("the next apply did not write again") } } @@ -407,7 +412,7 @@ func TestAFailedBindOrWriteEndsThePassAndReturnsInTime(t *testing.T) { if l.setCount() == 0 { t.Fatal("a bind that met no responders was not written again") } - if l.have(t, "n1.a").Phase != delivery.PhaseRunning { + if l.have(t, "n1.a").Phase != nodeengineclient.PhaseRunning { t.Error("the have written again does not say running") } } @@ -426,13 +431,13 @@ func TestAModuleLeavingTheDeclarationIsWrittenRemoved(t *testing.T) { revA, revB := l.kv["n1.a"].rev, l.kv["n1.b"].rev q.SayFacts(context.Background(), onlyA()) b := l.have(t, "n1.b") - if b.Phase != delivery.PhaseRemoved || b.Build != "b-2" { + if b.Phase != nodeengineclient.PhaseRemoved || b.Build != "b-2" { t.Errorf("b: %+v", b) } if l.kv["n1.b"].rev != revB+1 { t.Errorf("b was written at revision %d, want one over %d", l.kv["n1.b"].rev, revB) } - if l.kv["n1.a"].rev != revA || l.have(t, "n1.a").Phase != delivery.PhaseRunning { + if l.kv["n1.a"].rev != revA || l.have(t, "n1.a").Phase != nodeengineclient.PhaseRunning { t.Errorf("a changed: %+v at %d", l.have(t, "n1.a"), l.kv["n1.a"].rev) } before := l.setCount() @@ -449,11 +454,11 @@ func TestARemovedModuleAddedBackIsWrittenWithItsNewPhase(t *testing.T) { q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), onlyA()) q.SayFacts(context.Background(), appliedModules()) - if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseFailed || b.Build != "b-2" { + if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseFailed || b.Build != "b-2" { t.Errorf("b after it came back: %+v", b) } q.SayFacts(context.Background(), onlyA()) - if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseRemoved { + if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseRemoved { t.Errorf("b removed again: %+v", b) } } @@ -462,11 +467,11 @@ func TestARemovedModuleAddedBackIsWrittenWithItsNewPhase(t *testing.T) { // bucket is not created again. func TestAModuleNeverWrittenIsNotWrittenRemoved(t *testing.T) { l := newHaveLink() - l.put(t, "n1.c", delivery.Have{V: sdkkeys.Version, Build: "b-9", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.c", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-9", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) q, _ := aHaveQueue(l) q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), onlyA()) - if c := l.have(t, "n1.c"); c.Phase != delivery.PhaseRunning || l.kv["n1.c"].rev != 1 { + if c := l.have(t, "n1.c"); c.Phase != nodeengineclient.PhaseRunning || l.kv["n1.c"].rev != 1 { t.Errorf("c, which this node-engine never wrote, changed: %+v at %d", c, l.kv["n1.c"].rev) } // a key that vanished from the bucket is not created by the removal @@ -495,7 +500,7 @@ func TestARefusedRemovalIsWrittenByTheNextPass(t *testing.T) { l.failing = false l.hmu.Unlock() q.SayFacts(context.Background(), onlyA()) - if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseRemoved { + if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseRemoved { t.Errorf("b: %+v", b) } } @@ -504,13 +509,13 @@ func TestARefusedRemovalIsWrittenByTheNextPass(t *testing.T) { // before: one of them the new declaration lacks is written removed, one that is not in the bucket is not created. func TestARestartedNodeEngineWritesTheRemovalFromTheReportsBefore(t *testing.T) { l := newHaveLink() - l.put(t, "n1.a", delivery.Have{V: sdkkeys.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - l.put(t, "n1.b", delivery.Have{V: sdkkeys.Version, Build: "b-2", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + l.put(t, "n1.a", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + l.put(t, "n1.b", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-2", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) q, _ := aHaveQueue(l) // fresh: nothing written by this process r := onlyA() r.Before = []string{"a", "b", "c"} q.SayFacts(context.Background(), r) - if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseRemoved { + if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseRemoved { t.Errorf("b: %+v", b) } if _, there := l.kv["n1.c"]; there { @@ -563,24 +568,24 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( 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 { + if h.Fp != fp || h.Composed != dig || h.Phase != nodeengineclient.PhaseRunning || h.Health != nodeengineclient.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 { + if got := l.have(t, "n1.web"); got.Health != nodeengineclient.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 { + if got := l.have(t, "n1.web"); got.Health != nodeengineclient.HealthFailed || got.Fp != fp || got.Composed != dig { t.Fatalf("a failing check after the start window: %+v", got) } // The bus's user list written alone rides on the module's have, and an apply after it keeps it (ADR 0547 §8). - w := delivery.UserListWritten{Source: "n1.web." + fp, Revision: 4, Wanted: dig, Digest: dig, Users: 3, + w := nodeengine.UserListWritten{Source: "n1.web." + fp, Revision: 4, Wanted: dig, Digest: dig, Users: 3, At: time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)} q.NoteUserList(context.Background(), "web", w) - if got := l.have(t, "n1.web"); got.UserList == nil || got.UserList.Revision != 4 || got.FP != fp { + if got := l.have(t, "n1.web"); got.UserList == nil || got.UserList.Revision != 4 || got.Fp != fp { t.Fatalf("the user list is not in the have: %+v", got) } q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig}) @@ -589,24 +594,24 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( } // A failed apply waiting on a store's holder carries the pull it waits on in its have (ENG-9, read by node_haves). - wait := &delivery.PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution", + wait := &nodeengine.PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution", Began: time.Date(2026, 11, 4, 14, 0, 0, 0, time.UTC), Retried: "distribution settled"} q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Failed: true, Detail: "the pull failed", PullWait: wait}) - if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseFailed || got.PullWait == nil || got.PullWait.Holder != "distribution" { + if got := l.have(t, "n1.web"); got.Phase != nodeengineclient.PhaseFailed || got.PullWait == nil || got.PullWait.Holder != "distribution" { t.Fatalf("the failed have does not say the pull it waits on: %+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 == "" { + q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseHeld), Detail: "the repository is locked"}) + if got := l.have(t, "n1.web"); got.Phase != nodeengineclient.PhaseHeld || got.Detail == "" { t.Fatalf("held: %+v", got) } q.NoteRemoved(context.Background(), "web", "") - if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseRemoved { + if got := l.have(t, "n1.web"); got.Phase != nodeengineclient.PhaseRemoved { t.Fatalf("removed: %+v", got) } q.NoteNode(context.Background(), dig, compositions.Seals{"core-box#sealed": "core"}) - if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Composed != dig || len(n.SealedAt) != 1 || n.SealedAt["core-box#sealed"] != "core" { + if n, err := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Composed != dig || len(n.SealedAt) != 1 || n.SealedAt["core-box#sealed"] != "core" { t.Fatalf("the node key, with its own composition's sealed values by slot: %+v %v", n, err) } if n, ok := q.ReadNodeHave(context.Background()); !ok || n.Composed != dig || len(n.SealedAt) != 1 { @@ -615,11 +620,11 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( // The node's answers to placement candidates ride in the same key (ADR 0559 §4), and go when no group is answered. answers := map[string]nodeengine.CandidateAnswer{"p-1": {Proposal: "p-1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"web"}}} q.NoteCandidates(context.Background(), answers) - if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Candidates["p-1"].Proposal != "p-1" || n.Composed != dig { + if n, err := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Candidates["p-1"].Proposal != "p-1" || n.Composed != dig { t.Fatalf("the node key with its answers: %+v %v", n, err) } q.NoteCandidates(context.Background(), nil) - if n, _ := delivery.DecodeHaveNode(l.kv["n1.node"].value); n.Candidates != nil { + if n, _ := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); n.Candidates != nil { t.Fatalf("an answer left after its group went: %+v", n.Candidates) } } @@ -633,16 +638,16 @@ func TestAModuleWithNothingToJudgeIsOkOnceLookedAt(t *testing.T) { q, _ := aHaveQueue(l) q.SayFacts(context.Background(), Report{Modules: []ModuleFact{{Module: "mesh-host", Build: "b"}, {Module: "web", Build: "b"}, {Module: "broken", Build: "b", Failed: true, Detail: "x"}}}) - if h := l.have(t, "n1.mesh-host").Health; h != delivery.HealthUnknown { + if h := l.have(t, "n1.mesh-host").Health; h != nodeengineclient.HealthUnknown { t.Fatalf("before any look: %s", h) } if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) { t.Fatal("not said") } - if h := l.have(t, "n1.mesh-host").Health; h != delivery.HealthOK { + if h := l.have(t, "n1.mesh-host").Health; h != nodeengineclient.HealthOK { t.Fatalf("a running module with nothing to judge after a look: %s", h) } - if h := l.have(t, "n1.broken").Health; h == delivery.HealthOK { + if h := l.have(t, "n1.broken").Health; h == nodeengineclient.HealthOK { t.Fatalf("a failed module was said ok") } } @@ -654,17 +659,17 @@ func TestAModuleSaidAgainOverAnOlderEnginesUnknownIsWrittenWithThisEnginesHealth l := newHaveLink() fp := "src2:" + strings.Repeat("a", 64) dig := "sha256:" + strings.Repeat("b", 64) - l.put(t, "n1.xorg", delivery.Have{V: sdkkeys.Version, FP: fp, Composed: dig, Phase: delivery.PhaseRunning, - Health: delivery.HealthUnknown}) + l.put(t, "n1.xorg", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Fp: fp, Composed: dig, Phase: nodeengineclient.PhaseRunning, + Health: nodeengineclient.HealthUnknown}) q, _ := aHaveQueue(l) if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) { t.Fatal("not said") } - if h := l.have(t, "n1.xorg"); h.Health != delivery.HealthUnknown { + if h := l.have(t, "n1.xorg"); h.Health != nodeengineclient.HealthUnknown { t.Fatalf("a module the session does not hold was written: %+v", h) } q.NoteModule(context.Background(), ModuleFact{Module: "xorg", FP: fp, Composed: dig}) - if h := l.have(t, "n1.xorg"); h.Health != delivery.HealthOK || h.FP != fp || h.Composed != dig || h.Phase != delivery.PhaseRunning { + if h := l.have(t, "n1.xorg"); h.Health != nodeengineclient.HealthOK || h.Fp != fp || h.Composed != dig || h.Phase != nodeengineclient.PhaseRunning { t.Fatalf("said again after a look: %+v", h) } } @@ -679,26 +684,26 @@ func TestAHealthIsNotJudgedWhileAModuleIsApplied(t *testing.T) { old := ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("1", 64)} q.NoteModule(ctx, old) q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy})) - if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthFailed { + if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthFailed { t.Fatalf("the running module's failed look was not said: %+v", h) } next := ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("b", 64), Composed: "sha256:" + strings.Repeat("2", 64)} applying := next - applying.Phase = string(delivery.PhaseApplying) + applying.Phase = string(nodeengineclient.PhaseApplying) q.NoteModule(ctx, applying) - if h := l.have(t, "n1.ctl"); h.Phase != delivery.PhaseApplying || h.Health != delivery.HealthUnknown { + if h := l.have(t, "n1.ctl"); h.Phase != nodeengineclient.PhaseApplying || h.Health != nodeengineclient.HealthUnknown { t.Fatalf("applying carried the last look's health: %+v", h) } q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy})) - if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthUnknown { + if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthUnknown { t.Fatalf("a look during the apply judged it: %+v", h) } q.NoteModule(ctx, next) - if h := l.have(t, "n1.ctl"); h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthUnknown { + if h := l.have(t, "n1.ctl"); h.Phase != nodeengineclient.PhaseRunning || h.Health != nodeengineclient.HealthUnknown { t.Fatalf("running before a look after the apply: %+v", h) } q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateHealthy})) - if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthOK { + if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthOK { t.Fatalf("the look after the apply was not taken: %+v", h) } } @@ -711,7 +716,7 @@ func TestAHealthIsNotCarriedOverToAnotherComposition(t *testing.T) { q.NoteModule(ctx, ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("a", 64)}) q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy})) q.NoteModule(ctx, ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("b", 64)}) - if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthUnknown { + if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthUnknown { t.Fatalf("the old composition's look judged the new one: %+v", h) } } @@ -724,11 +729,11 @@ func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) { ctx := context.Background() q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1"}}, Health: healthOf(map[string]string{"a": StateUnhealthy})}) - if h := l.have(t, "n1.a"); h.Health != delivery.HealthFailed { + if h := l.have(t, "n1.a"); h.Health != nodeengineclient.HealthFailed { t.Fatalf("the failed look was not said: %+v", h) } q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1", FP: "src2:" + strings.Repeat("c", 64)}}}) - if h := l.have(t, "n1.a"); h.Health != delivery.HealthUnknown { + if h := l.have(t, "n1.a"); h.Health != nodeengineclient.HealthUnknown { t.Fatalf("the old composition's look judged the new one: %+v", h) } select { @@ -750,18 +755,18 @@ func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) { // key that does not exist is not made up and is said once; a writer that drops the list again is written over. func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) { l := newHaveLink() - older := delivery.Have{V: sdkkeys.Version, Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + older := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} l.put(t, "n1.nats", older) q, said := aHaveQueue(l) dig := "sha256:" + strings.Repeat("c", 64) - w := delivery.UserListWritten{Source: "n1.nats", Revision: 7, Wanted: dig, Digest: dig, Users: 58, + w := nodeengine.UserListWritten{Source: "n1.nats", Revision: 7, Wanted: dig, Digest: dig, Users: 58, At: time.Date(2026, 10, 26, 12, 26, 51, 0, time.UTC)} q.NoteUserList(context.Background(), "nats", w) got := l.have(t, "n1.nats") if got.UserList == nil || got.UserList.Users != 58 || got.UserList.Digest != dig { t.Fatalf("the user list is not in the have: %+v", got) } - if got.Build != "build-1" || got.Phase != delivery.PhaseRunning || got.Health != delivery.HealthOK { + if got.Build != "build-1" || got.Phase != nodeengineclient.PhaseRunning || got.Health != nodeengineclient.HealthOK { t.Fatalf("the merge lost the key's own fields: %+v", got) } rev := l.kv["n1.nats"].rev @@ -794,7 +799,7 @@ func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) { } fp := "src2:" + strings.Repeat("a", 64) q.NoteModule(context.Background(), ModuleFact{Module: "other", FP: fp, Composed: dig}) - if got := l.have(t, "n1.other"); got.UserList == nil || got.FP != fp { + if got := l.have(t, "n1.other"); got.UserList == nil || got.Fp != fp { t.Fatalf("the first have of the module lost the list: %+v", got) } } diff --git a/internal/link/health_test.go b/internal/link/health_test.go index cc17b61..8a87e3b 100644 --- a/internal/link/health_test.go +++ b/internal/link/health_test.go @@ -3,12 +3,11 @@ package link import ( "context" "encoding/json" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "slices" "strings" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // A health statement is said on the link open now, as the machine's own word on its own subject, and @@ -60,7 +59,7 @@ func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) { since := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC) look := func(streak int, writer string) Health { return Health{Contract: 7, At: time.Now(), Network: &NetworkHealth{State: StateUnhealthy, Since: since, Parts: []NetworkPart{ - {Part: "resolv-conf", State: StateUnhealthy, Writer: writer, Owner: "dnsmasq", Since: since, Streak: streak}}}, + {Part: "resolv-conf", State: StateUnhealthy, Writer: writer, Owner: "dnsmasq", Since: since, Streak: int64(streak)}}}, Units: &UnitsHealth{State: "degraded", Failed: []FailedUnit{{Unit: "cups.service", Scope: "system", Since: since}}}, Resources: []ResourceHealth{ {Module: "brother", Resource: "brother.scanner", Kind: KindAccount, Account: "jochen", State: StateUnhealthy, @@ -121,12 +120,12 @@ func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) { t.Fatalf("step %d: wrote %v (want %v), %v", i, wrote, step.wrote, err) } } - got, err := delivery.DecodeNodeHealth(store.values["anchor"]) + got, err := nodeengineclient.DecodeHealth(store.values["anchor"]) if err != nil || got.Node != "anchor" || got.Network.Parts[0].Writer != "openvpn" || !got.Peers[0].Seen || got.At.IsZero() { t.Fatalf("the key holds %+v, %v", got, err) } // The key's bytes are mesh-sdk's layout: decoded and encoded again by it, the same bytes. - if again, err := delivery.EncodeNodeHealth(got); err != nil || string(again) != string(store.values["anchor"]) { + if again, err := nodeengineclient.EncodeHealth(got); err != nil || string(again) != string(store.values["anchor"]) { t.Fatalf("the key's bytes are not the sdk's layout: %s\n%s (%v)", store.values["anchor"], again, err) } if store.puts != 3 { diff --git a/internal/link/messages.go b/internal/link/messages.go index 96d0475..16c32eb 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -1,11 +1,10 @@ package link import ( + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/health" "github.com/novox/mesh-host/internal/compositions" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" ) // The wire formats shared with the control plane, which defines them separately because this @@ -304,10 +303,10 @@ const ReasonUsedAsFound = "used as found:" // The machine's networking and its service managers in a statement are the node's health report's parts (mesh-sdk // delivery, issue 774): one layout for the message, the report and the watchman that reads it. type ( - NetworkHealth = delivery.NetworkHealth - NetworkPart = delivery.NetworkPart - UnitsHealth = delivery.UnitsHealth - FailedUnit = delivery.FailedUnit + NetworkHealth = nodeengine.NetworkHealth + NetworkPart = nodeengine.NetworkPart + UnitsHealth = nodeengine.UnitsHealth + FailedUnit = nodeengine.FailedUnit ) // The states a long-running resource is said in (ADR 0240 §4). @@ -640,12 +639,12 @@ type ModuleFact struct { // Judgement and Waits are, in phase waiting-yes, the judgement of the move and the composition waiting (ADR 0547 §3). Judgement string Waits string - // BuildAlone says, in phase waiting-yes, the move changes the build alone (ADR 0601 §1, delivery.Have.BuildAlone). + // BuildAlone says, in phase waiting-yes, the move changes the build alone (ADR 0601 §1, nodeengine.HaveByNodeModuleValue.BuildAlone). BuildAlone bool - // Kept is what the node keeps of the module as it runs, for a manifest that does not say it yet (delivery.Have.Kept). + // Kept is what the node keeps of the module as it runs, for a manifest that does not say it yet (nodeengine.HaveByNodeModuleValue.Kept). Kept []string - // Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt). + // Sealed is the values the composition it names carries sealed to this node's key, by slot (nodeengine.HaveByNodeModuleValue.SealedAt). Sealed compositions.Seals - // PullWait is, on a failed fact, the pull from a store its apply waits on (delivery.Have.PullWait, issue 1966 ENG-9). - PullWait *delivery.PullWait + // PullWait is, on a failed fact, the pull from a store its apply waits on (nodeengine.HaveByNodeModuleValue.PullWait, issue 1966 ENG-9). + PullWait *nodeengine.PullWait } diff --git a/internal/link/nodehealth.go b/internal/link/nodehealth.go index 5aa2d87..1d6f293 100644 --- a/internal/link/nodehealth.go +++ b/internal/link/nodehealth.go @@ -4,13 +4,16 @@ import ( "context" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/keys" + "github.com/novox/mesh-host/internal/buckets" "slices" "sort" "strings" "time" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" ) @@ -30,17 +33,17 @@ import ( // - node-account-fallback: Fallback, the engine's fall back to its old login during a rotation (ADR 0583 §6). // -// The report's layout is mesh-sdk's (delivery.NodeHealth): the bucket, its version and its parts. +// The report's layout is mesh-sdk's (nodeengine.HealthByNodeValue): the bucket, its version and its parts. const ( - HealthBucket = delivery.NodeHealthBucket - NodeHealthVersion = delivery.NodeHealthVersion + HealthBucket = buckets.Health + NodeHealthVersion = keys.Version ) type ( - NodeHealth = delivery.NodeHealth - PeerSeen = delivery.PeerSeen - NodeAccount = delivery.NodeAccount - NodeDirectory = delivery.NodeDirectory + NodeHealth = nodeengine.HealthByNodeValue + PeerSeen = nodeengine.PeerSeen + NodeAccount = nodeengine.NodeAccount + NodeDirectory = nodeengine.NodeDirectory ) // ReasonReloginNeeded starts an account's reason when its session needs a new login (accounts.ReasonRelogin). @@ -83,7 +86,7 @@ const TowardHub = "hub" // streaks, each unhealthy part naming the machines it points at (machines), its machine units, and each account and // directory of its resources that the six alerts read. func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) NodeHealth { - out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units, Contract: h.Contract} + out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units, Contract: int64(h.Contract)} if len(peers) > 0 { out.Peers = append([]PeerSeen(nil), peers...) sort.Slice(out.Peers, func(i, k int) bool { return out.Peers[i].Key < out.Peers[k].Key }) @@ -108,7 +111,7 @@ func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) No } if r.Kind == KindAccount && r.Root == declaration.RootNever { // The account declared never to become root without a person, judged (ADR 0266): what DA and D-root read. - out.Root = append(out.Root, delivery.RootVerdict{Account: r.Account, Module: r.Module, Resource: r.Resource, + out.Root = append(out.Root, nodeengine.RootAccountVerdict{Account: r.Account, Module: r.Module, Resource: r.Resource, State: r.State, Reason: r.Reason, Since: r.Since}) } if r.Kind == KindDirectory && strings.HasPrefix(r.Reason, ReasonUsedAsFound) { @@ -191,16 +194,16 @@ func writeNodeHealth(ctx context.Context, store HealthStore, last **NodeHealth, return false, fmt.Errorf("the health report of %s cannot be read: %w", h.Node, err) } if raw != nil { - if was, err := delivery.DecodeNodeHealth(raw); err == nil { + if was, err := nodeengineclient.DecodeHealth(raw); err == nil { *last = &was } } } - if *last != nil && delivery.SameNodeHealth(**last, h) { + if *last != nil && nodeengineclient.SameHealth(**last, h) { return false, nil } h.At = now().UTC() - raw, err := delivery.EncodeNodeHealth(h) + raw, err := nodeengineclient.EncodeHealth(h) if err != nil { return false, err } diff --git a/internal/link/record.go b/internal/link/record.go index cd6a30d..b228959 100644 --- a/internal/link/record.go +++ b/internal/link/record.go @@ -2,6 +2,7 @@ package link import ( "encoding/json" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "slices" "sort" @@ -9,11 +10,10 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The node's record (novox/hq ADR 0555 §3, issue 1126): what the machine observes of itself, written in its own have key -// `.node` (delivery.HaveNode.Record) with its facts, so every node and the parts that compose read it from the bus +// `.node` (nodeengine.HaveByNodeNodeValue.Record) with its facts, so every node and the parts that compose read it from the bus // and hear its change as the key's. Each value is the one the controller took from this node's enrolment and reports // until now (its profile's present capabilities and networks, its outward links, its health statement's contract, and // the public halves of the keys it enrolled with), read here from the same sources, never from the controller. @@ -44,7 +44,7 @@ var readsStatedAt = time.Now().UTC() // recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that // says nothing of the machine, such as a rekey), so the record last written stands. -func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { +func recordOf(node string, r Report, keys RecordKeys) *nodeengine.NodeRecord { if r.Profile == nil { return nil } @@ -61,7 +61,7 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { if err != nil || json.Unmarshal(raw, &p) != nil { return nil } - rec := &delivery.NodeRecord{V: sdkkeys.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, + rec := &nodeengine.NodeRecord{V: sdkkeys.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts, ManifestReads: manifestReads()} for _, c := range p.Capabilities { @@ -77,13 +77,15 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { sort.Strings(rec.Capabilities) if p.Machine != nil { rec.NetworksSaid = true - rec.Networks = p.Machine.Networks + for _, n := range p.Machine.Networks { + rec.Networks = append(rec.Networks, nodeengine.AttachedNetwork{Link: n.Link, Network: n.Network}) + } } if len(r.Outward) > 0 { rec.OutwardLinks = append([]string(nil), r.Outward...) } if r.Health != nil { - rec.Contract = r.Health.Contract + rec.Contract = int64(r.Health.Contract) } return rec } diff --git a/internal/link/rotate.go b/internal/link/rotate.go index 12d06bb..e0474e9 100644 --- a/internal/link/rotate.go +++ b/internal/link/rotate.go @@ -3,6 +3,7 @@ package link import ( "context" "fmt" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "sync" "time" @@ -35,9 +36,9 @@ type Rotator struct { failures int // fellBack is the key's `since` a fallback was made for: the same rotation is not tried again. fellBackSince string - // fallen is the fall back the node's health report carries (delivery.LoginFallback), from which the watchman raises + // fallen is the fall back the node's health report carries (nodeengine.LoginFallback), from which the watchman raises // node-account-fallback (ADR 0583 §6), until the vault's key of this node says anything new. - fallen *delivery.LoginFallback + fallen *nodeengine.LoginFallback switchedSince string } @@ -162,13 +163,13 @@ func (r *Rotator) AuthFailed(login string) { if r.Now != nil { now = r.Now } - r.fallen = &delivery.LoginFallback{Node: r.Logins.node, From: login, Why: text, At: now().UTC()} + r.fallen = &nodeengine.LoginFallback{Node: r.Logins.node, From: login, Why: text, At: now().UTC()} r.old, r.failures = "", 0 r.fellBackSince = r.switchedSince } // Fallback is the fall back the node's health report carries now, nil when there is none. -func (r *Rotator) Fallback() *delivery.LoginFallback { +func (r *Rotator) Fallback() *nodeengine.LoginFallback { if r == nil { return nil } diff --git a/internal/link/rotate_test.go b/internal/link/rotate_test.go index 57a8e32..0b3c1fc 100644 --- a/internal/link/rotate_test.go +++ b/internal/link/rotate_test.go @@ -3,6 +3,8 @@ package link import ( "context" "errors" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "slices" "strings" "testing" @@ -140,7 +142,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t * t.Fatalf("after the bound: %s, fall back %+v", cur, fb) } // The health report carries it, from which the watchman raises node-account-fallback. - if _, err := delivery.EncodeNodeHealth(delivery.NodeHealth{Node: "ace", Fallback: fb}); err != nil { + if _, err := nodeengineclient.EncodeHealth(nodeengine.HealthByNodeValue{Node: "ace", Fallback: fb}); err != nil { t.Errorf("the fall back is no health report's: %v", err) } steps := len(f.steps) diff --git a/internal/link/session.go b/internal/link/session.go index 9af5818..a05b5f3 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -12,7 +12,6 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -23,7 +22,7 @@ import ( // receives, orders or reconciles a whole declaration. type Session struct { // Fallback is the engine's fall back to its old login its health report carries now (Rotator.Fallback); nil says none. - Fallback func() *delivery.LoginFallback + Fallback func() *nodeengine.LoginFallback // linkedUser is the bus user the link open now authenticated as, guarded by mu (WhenLinkedAs). linkedUser string // Membership is the node and the mesh's key a controller's ask is verified with. diff --git a/internal/network/drill_test.go b/internal/network/drill_test.go index fa22fba..0d39b82 100644 --- a/internal/network/drill_test.go +++ b/internal/network/drill_test.go @@ -46,7 +46,7 @@ func TestDrillAResolverFileRewrittenInAThrowawayMachine(t *testing.T) { Network: &link.NetworkHealth{State: st.State, Since: st.Since.UTC(), Parts: []link.NetworkPart{}}} for _, p := range st.Parts { h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason, - Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak}) + Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: int64(p.Streak)}) } said = append(said, h) raw, _ := json.Marshal(st) diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index aaf30a3..5ca69a1 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -227,7 +227,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi case laidErr != nil && baseErr != nil: // The node composes none of its own without the candidate either: the candidate changes nothing of that. a.Verdict, a.Modules = nec.Composed, []nodeengine.Module{} - a.Own = nodeengine.Module{Module: delivery.HaveNodeWord, State: nec.ModuleHeld, Words: laidErr.Error()} + a.Own = nodeengine.Module{Module: nec.NodeWord, State: nec.ModuleHeld, Words: laidErr.Error()} a.Took = c.now().Sub(began).Milliseconds() return a case laidErr != nil: @@ -244,7 +244,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi one := func(module string) (nodeengine.Module, bool) { m := nodeengine.Module{Module: module} if module == "" { - m.Module = delivery.HaveNodeWord + m.Module = nec.NodeWord } if why, held := laidHeld[module]; held { m.State, m.Words = nec.ModuleRefused, why @@ -295,7 +295,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi if own, ok := one(""); ok { a.Own = own } else { - a.Own = nodeengine.Module{Module: delivery.HaveNodeWord, State: nec.ModuleHeld, + a.Own = nodeengine.Module{Module: nec.NodeWord, State: nec.ModuleHeld, Words: "the node composes no composition of its own"} } refused = refused || a.Own.State == nec.ModuleRefused diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index 18433af..6b1adcf 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -3,6 +3,7 @@ package selfcompose import ( "context" "encoding/json" + "github.com/novox/mesh-host/internal/buckets" "strings" "sync/atomic" "testing" @@ -83,7 +84,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { } place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) - have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + have, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) deadline := time.Now().Add(5 * time.Second) watching := func() int { b.mu.Lock(); defer b.mu.Unlock(); return len(b.watches) } @@ -91,9 +92,9 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { time.Sleep(5 * time.Millisecond) } b.put(t, delivery.PlacementBucket, "placement.n1", place) - b.put(t, delivery.HaveBucket, "n1.node", have) + b.put(t, buckets.Have, "n1.node", have) b.put(t, delivery.ModulesBucket, "lemurs", mod) - for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket} { + for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket} { b.send(bucket, agent.Update{Caught: true}) } for !s.ready.Load() && time.Now().Before(deadline) { @@ -106,7 +107,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { } if a.Verdict != nec.Composed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" || a.Modules[0].State != nec.ModuleComposed || a.Modules[0].Fp != fpNew || !a.Modules[0].Changed || - a.Own.Module != delivery.HaveNodeWord || a.Took > nec.AnswerBound.Milliseconds() { + a.Own.Module != nec.NodeWord || a.Took > nec.AnswerBound.Milliseconds() { t.Errorf("an accepted candidate answered %+v", a) } // Issue 774: the input it sends the sandbox says how it composed the node (the builds), so the sandbox composes it @@ -181,7 +182,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { s := NewShadow(env, nil) s.c.placements["n1"] = delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}}} - s.c.records["n1"] = delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}} + s.c.records["n1"] = nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}} s.c.modules["lemurs"] = delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"} s.c.modules["other"] = delivery.Module{FP2: fp, Manifest: digestOther, Repository: "novox/x", Commit: "c"} s.ready.Store(true) diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index 48d3211..86ac8a7 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -2,6 +2,9 @@ package selfcompose import ( "context" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "strings" @@ -85,18 +88,18 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { } place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) - have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) b.put(t, delivery.PlacementBucket, "placement.n1", place) - b.put(t, delivery.HaveBucket, "n1.node", have) + b.put(t, buckets.Have, "n1.node", have) b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "oldie", omod) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } for u := next(t, out); !u.Caught; u = next(t, out) { diff --git a/internal/selfcompose/data.go b/internal/selfcompose/data.go index fb57685..9fe8fc5 100644 --- a/internal/selfcompose/data.go +++ b/internal/selfcompose/data.go @@ -4,13 +4,15 @@ import ( "bytes" "context" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "strings" "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" ) @@ -62,7 +64,7 @@ func (c *Composer) publishData(ctx context.Context) { items, unplaced, held, err := dataItemsOf(ctx, itemsReader{reader{c: c}}, c.env.Mesh, scope) if err != nil { c.sayData("the data items of this node's modules cannot be worked out, and what the engine's state " + - delivery.EngineDataBucket + " holds stands; " + c.oweData() + ": " + err.Error()) + buckets.Data + " holds stands; " + c.oweData() + ": " + err.Error()) return } // A module held for a refused manifest declares nothing this pass can read: it is named, its other modules' items @@ -83,7 +85,7 @@ func (c *Composer) publishData(ctx context.Context) { c.mu.Unlock() wrote, removed, err := c.writeData(ctx, items, digests, held) if err != nil { - c.sayData("the data items of this node's modules are not all kept in " + delivery.EngineDataBucket + + c.sayData("the data items of this node's modules are not all kept in " + buckets.Data + "; " + c.oweData() + ": " + err.Error()) return } @@ -93,7 +95,7 @@ func (c *Composer) publishData(ctx context.Context) { c.sayData("") if wrote+removed > 0 { c.env.Say(fmt.Sprintf("the data items of this node's modules: %d key(s) of %s written, %d deleted", - wrote, delivery.EngineDataBucket, removed)) + wrote, buckets.Data, removed)) } } @@ -145,7 +147,7 @@ func (c *Composer) digestsByModule() map[string]string { func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.PlacedDataItem, digests map[string]string, held map[string]string) (wrote, removed int, err error) { store := c.env.Data if c.dataKept == nil { - pattern, err := delivery.EngineDataNodeKeys(c.env.Node) + pattern, err := nodeengineclient.DataNodeKeys(c.env.Node) if err != nil { return 0, 0, err } @@ -176,7 +178,7 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P if len(items[module]) == 0 { continue } - key, err := delivery.EngineDataKey(c.env.Node, module) + key, err := nodeengineclient.DataKey(c.env.Node, module) if err != nil { failed = append(failed, err.Error()) continue @@ -185,7 +187,7 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P if !ok { digest = digests[""] // run inside the node's own composition } - raw, err := delivery.EncodeEngineData(delivery.EngineData{Node: c.env.Node, Module: module, Digest: digest, Items: items[module]}) + raw, err := nodeengineclient.EncodeData(nodeengine.DataByNodeModuleValue{Node: c.env.Node, Module: module, Digest: digest, Items: placedItems(items[module])}) if err != nil { failed = append(failed, err.Error()) continue @@ -232,9 +234,19 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P // heldKey is whether key is the key of a module held for a refused manifest, which stands as it was. func heldKey(node, key string, held map[string]string) bool { for module := range held { - if k, err := delivery.EngineDataKey(node, module); err == nil && k == key { + if k, err := nodeengineclient.DataKey(node, module); err == nil && k == key { return true } } return false } + +// placedItems are a module's data items as the state data carries them. +func placedItems(in []catalogue.PlacedDataItem) []nodeengine.PlacedDataItem { + out := make([]nodeengine.PlacedDataItem, 0, len(in)) + for _, i := range in { + out = append(out, nodeengine.PlacedDataItem{Module: i.Module, Item: i.ID, Class: i.Class, Path: i.Path, + CoveredBy: i.CoveredBy, Protection: i.Protection, Measure: i.Measure}) + } + return out +} diff --git a/internal/selfcompose/data_test.go b/internal/selfcompose/data_test.go index c92e25b..dc32133 100644 --- a/internal/selfcompose/data_test.go +++ b/internal/selfcompose/data_test.go @@ -3,6 +3,9 @@ package selfcompose import ( "context" "errors" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "strings" @@ -86,7 +89,7 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { var said []string env := Env{Node: "n1", Data: store, Say: func(s string) { said = append(said, s) }} // The engine's grant lets it read its own node's keys, as the listing at a run's first pass does, and no other's. - if !grantAllows(t, "n1", delivery.EngineDataBucket, "n1.*") || grantAllows(t, "n1", delivery.EngineDataBucket, "n2.*") { + if !grantAllows(t, "n1", buckets.Data, "n1.*") || grantAllows(t, "n1", buckets.Data, "n2.*") { t.Error("the node's grant reads other keys of the engine's state data than its own") } c := New(env) @@ -105,15 +108,15 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { deletes []string asked int // want is each key's items and digest after the step. - want map[string]delivery.EngineData + want map[string]nodeengine.DataByNodeModuleValue }{ {name: "the run's first pass writes each module's items and deletes what left", due: true, items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}, "bare": nil}, digests: map[string]string{webKey: "sha256:w1", nodeKey: "sha256:n1"}, puts: []string{"n1.oldie", "n1.web"}, deletes: []string{"n1.gone"}, asked: 1, - want: map[string]delivery.EngineData{ - "n1.web": {Module: "web", Digest: "sha256:w1", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}}, - "n1.oldie": {Module: "oldie", Digest: "sha256:n1", Items: []catalogue.PlacedDataItem{item("oldie", "x", "/srv/oldie")}}}}, + want: map[string]nodeengine.DataByNodeModuleValue{ + "n1.web": {Module: "web", Digest: "sha256:w1", Items: placedItems([]catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")})}, + "n1.oldie": {Module: "oldie", Digest: "sha256:n1", Items: placedItems([]catalogue.PlacedDataItem{item("oldie", "x", "/srv/oldie")})}}}, {name: "a pass that handed nothing new reads nothing", due: false, asked: 1}, {name: "a pass that changes no module's items writes nothing", due: true, items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}}, @@ -126,8 +129,8 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { {name: "a module whose composition changed its items is written again", due: false, items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db2")}, "oldie": {item("oldie", "x", "/srv/oldie")}}, digests: map[string]string{webKey: "sha256:w2", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 5, - want: map[string]delivery.EngineData{ - "n1.web": {Module: "web", Digest: "sha256:w2", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db2")}}}}, + want: map[string]nodeengine.DataByNodeModuleValue{ + "n1.web": {Module: "web", Digest: "sha256:w2", Items: placedItems([]catalogue.PlacedDataItem{item("web", "db", "/srv/web/db2")})}}}, {name: "a module that left the node is deleted", due: true, items: map[string][]catalogue.PlacedDataItem{"oldie": {item("oldie", "x", "/srv/oldie")}}, digests: map[string]string{nodeKey: "sha256:n1"}, deletes: []string{"n1.web"}, asked: 6}, @@ -136,8 +139,8 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}}, held: map[string]string{"oldie": "the manifest of oldie is refused: a bar item is no icon"}, digests: map[string]string{webKey: "sha256:w3", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 7, - want: map[string]delivery.EngineData{ - "n1.web": {Module: "web", Digest: "sha256:w3", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}}}}, + want: map[string]nodeengine.DataByNodeModuleValue{ + "n1.web": {Module: "web", Digest: "sha256:w3", Items: placedItems([]catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")})}}}, } { t.Run(step.name, func(t *testing.T) { store.mu.Lock() @@ -159,7 +162,7 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { t.Errorf("owed a pass: %v after %s; want after %s", c.owed.Load(), wait, step.owed) } for key, want := range step.want { - got, err := delivery.DecodeEngineData(store.kv[key]) + got, err := nodeengineclient.DecodeData(store.kv[key]) if err != nil || got.Node != "n1" || got.Module != want.Module || got.Digest != want.Digest || !slices.Equal(got.Items, want.Items) { t.Errorf("%s holds %+v (%v), want %+v", key, got, err, want) diff --git a/internal/selfcompose/packages.go b/internal/selfcompose/packages.go index 45b4801..169538b 100644 --- a/internal/selfcompose/packages.go +++ b/internal/selfcompose/packages.go @@ -10,7 +10,6 @@ import ( engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "time" - ) // **The packages this node's compositions declare, kept by its engine** (novox/hq ADR 0565 §6, §15; issue 1953). The diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 4b115df..81dff9c 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -19,6 +19,9 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" @@ -76,7 +79,7 @@ type Env struct { Unseal func(sealed string) ([]byte, error) // Held is the sealed values a module's have says it runs, kept by the agent on the bus: what runs after a restart, // before the agent applied anything, and after the switch once the kept declaration is old. By slot - // (delivery.Have.SealedAt); legacy is the list a have written before slots holds (delivery.Have.Sealed), reused + // (nodeengine.HaveByNodeModuleValue.SealedAt); legacy is the list a have written before slots holds (nodeengine.HaveByNodeModuleValue.Sealed), reused // only within its own module. // An error is a have the bus did not answer for, never one absent: the pass is then tried again (BusFailed). Held func(module string) (slots compositions.Seals, legacy []string, err error) @@ -131,7 +134,7 @@ type Composer struct { // placementKeys is the seat's state placement key by key in either form, each node's placement assembled from it // (delivery.PlacementKeys; research 072 row 37): a write of the holder's under way is never composed half. placementKeys *delivery.PlacementKeys - records map[string]delivery.NodeRecord + records map[string]nodeengine.NodeRecord // inOwn is, by module, whether this node's have of it names no fingerprint of the new form (mesh-wireguard, a module // at a build of the old form): the node runs it inside its own composition, and composes it there again (issue 1126). inOwn map[string]bool @@ -298,7 +301,7 @@ func New(env Env) *Composer { env.Say = func(string) {} } return &Composer{env: env, wants: map[string]delivery.Want{}, placements: map[string]delivery.Placement{}, placementKeys: delivery.NewPlacementKeys(), - records: map[string]delivery.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{}, + records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{}, manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]delivery.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} } @@ -357,8 +360,8 @@ func (c *Composer) inputs() []input { // Every node's record, and this node's own haves (what it runs inside its own composition): two watches, each // within the node's grant (buscompose: readGrants of the have bucket at `*.node`, NodeKeysGrants at `.>`). // A watch of every key is refused by the bus (issue 1126, 2026-10-27 09:21: the fold's `>` stopped every agent). - {delivery.HaveBucket, "*." + delivery.HaveNodeWord, nil, (*Composer).takeRecordChanged}, - {delivery.HaveBucket, node + ".>", nil, (*Composer).takeModuleHave}, + {buckets.Have, "*." + nodeengineclient.NodeWord, nil, (*Composer).takeRecordChanged}, + {buckets.Have, node + ".>", nil, (*Composer).takeModuleHave}, {delivery.ModulesBucket, ">", (*Composer).takeModule, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, // The builder's builds: a pass that held a module for a build not yet done is made again when it lands (issue @@ -690,13 +693,13 @@ func (c *Composer) takePlacement(u agent.Update) { } func (c *Composer) takeRecord(u agent.Update) { - node, _, isNode, err := delivery.ParseHaveKey(u.Key) + node, _, isNode, err := nodeengineclient.ParseHaveKey(u.Key) if err != nil || !isNode { return } c.mu.Lock() defer c.mu.Unlock() - h, err := delivery.DecodeHaveNode(u.Value) + h, err := nodeengineclient.DecodeHaveNode(u.Value) if u.Deleted || err != nil || h.Record == nil { delete(c.records, node) return @@ -715,13 +718,13 @@ func (c *Composer) takeRecordChanged(u agent.Update) bool { // id; mesh-wireguard: none). Read from the haves, which outlast a restart, never from what the agent has applied // this run: a fresh agent composes before it applies anything (review of the fold, round 1). func (c *Composer) takeModuleHave(u agent.Update) bool { - node, module, isNode, err := delivery.ParseHaveKey(u.Key) + node, module, isNode, err := nodeengineclient.ParseHaveKey(u.Key) if err != nil || isNode || node != c.env.Node { return false } in := false if !u.Deleted { - if h, err := delivery.DecodeHave(u.Value); err == nil { + if h, err := nodeengineclient.DecodeHave(u.Value); err == nil { // A have that names nothing it runs, failed (the node could not compose a module it never ran: NotComposed), is // no module run inside the node's own composition: taken as one, it was composed from its manifest past its // build (issue 1126, review round 1 of the builds). @@ -729,9 +732,9 @@ func (c *Composer) takeModuleHave(u agent.Update) bool { // mesh-host #189, novox's postgres 2026-10-31): taken as one, it was composed from its manifest and refused // for its secret reader before the start backed it up again. Any other have naming nothing keeps the rule // (waiting for a yes inside the node's own stays in). - interrupted := h.Phase == delivery.PhaseHeld && h.Detail == agent.BackupInterrupted - runsNothing := (h.Phase == delivery.PhaseFailed || interrupted) && h.FP == "" && h.Composed == "" - in = h.Phase != delivery.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.FP, delivery.SourcePrefix2) + interrupted := h.Phase == nodeengineclient.PhaseHeld && h.Detail == agent.BackupInterrupted + runsNothing := (h.Phase == nodeengineclient.PhaseFailed || interrupted) && h.Fp == "" && h.Composed == "" + in = h.Phase != nodeengineclient.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.Fp, delivery.SourcePrefix2) } } c.mu.Lock() @@ -749,7 +752,7 @@ func (c *Composer) takeModuleHave(u agent.Update) bool { // inOwnOf is, of the modules p places, those the node runs inside its own composition (c.inOwn), sorted; c.mu held. // Machines is, from the placements this composer holds, each node by its address on the private network, and the hub's -// node: what names the machine a failing network part points at (delivery.NetworkPart.Targets, issue 774). Empty +// node: what names the machine a failing network part points at (nodeengine.NetworkPart.Targets, issue 774). Empty // before any placement is read. func (c *Composer) Machines() (byAddress map[string]string, hub string) { c.mu.Lock() @@ -1079,7 +1082,7 @@ func (c *Composer) refused(why string, held map[string]string) { c.refusal = said c.held = heldModules(held) if why != "" { - c.held = append(c.held, delivery.HaveNodeWord) + c.held = append(c.held, nodeengineclient.NodeWord) } c.mu.Unlock() if !same && c.env.Refused != nil { @@ -1374,10 +1377,10 @@ func (r reader) Placements(context.Context) (map[string]delivery.Placement, erro return out, nil } -func (r reader) Records(context.Context) (map[string]delivery.NodeRecord, error) { +func (r reader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { r.c.mu.Lock() defer r.c.mu.Unlock() - out := make(map[string]delivery.NodeRecord, len(r.c.records)) + out := make(map[string]nodeengine.NodeRecord, len(r.c.records)) for k, v := range r.c.records { out[k] = v } diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 7ae5ad3..9cada98 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -8,7 +8,9 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -189,19 +191,19 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { omod, _ := delivery.EncodeModule(delivery.Module{FP2: delivery.SourcePrefix2 + strings.Repeat("c", 64), Manifest: odigest, Repository: "novox/o", Commit: "c"}) place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) - have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) b.put(t, delivery.PlacementBucket, "placement.n1", place) - b.put(t, delivery.HaveBucket, "n1.node", have) - oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.put(t, delivery.HaveBucket, "n1.oldie", oldHave) + b.put(t, buckets.Have, "n1.node", have) + oldHave, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.put(t, buckets.Have, "n1.oldie", oldHave) b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "oldie", omod) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } got := map[string]bool{} @@ -696,10 +698,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { bkey, _ := delivery.BuilderBuildKey("tool", next2) tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = building tb.put(t, delivery.PlacementBucket, "placement.n1", tplace) - tb.put(t, delivery.HaveBucket, "n1.node", have) + tb.put(t, buckets.Have, "n1.node", have) tb.put(t, delivery.ModulesBucket, "tool", tmod) tb.put(t, delivery.AssignerWantsBucket, "want.n1.tool", twant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, + for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { tb.send(bucket, agent.Update{Caught: true}) } @@ -1049,7 +1051,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // What n1 runs: lemurs and its own composition, as its composer composes them from the trunk. running, _, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]delivery.Placement{"n1": trunk}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}, + records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: mods, manifests: map[string][]byte{digest: []byte(lemurs), kdigest: []byte(keeper)}}, frombus.Mesh{}, frombus.Scope{Node: "n1"}, map[string]string{"lemurs": fp}) if err != nil { t.Fatal(err) @@ -1085,10 +1087,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { time.Sleep(30 * time.Millisecond) sb.missing(delivery.PlacementCandidatesBucket, false) sb.made(6) - rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + rec, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) tp, _ := delivery.EncodePlacement(trunk) sb.put(delivery.PlacementBucket, "placement.n1", tp, 1) - sb.put(delivery.HaveBucket, "n1.node", rec, 1) + sb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { raw, _ := delivery.EncodeModule(m) sb.put(delivery.ModulesBucket, name, raw, 1) @@ -1201,7 +1203,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } } // The node's own key changing (its answers written) composes nothing again; a group's key deleted drops its answer. - sb.put(delivery.HaveBucket, "n1.node", rec, 2) + sb.put(buckets.Have, "n1.node", rec, 2) select { case a := <-answered: t.Errorf("answers handed again for nothing changed: %+v", a) @@ -1232,13 +1234,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } } lemursHave := func(detail string, rev uint64) { - raw, err := delivery.EncodeHave(delivery.Have{FP: fp, Composed: lemursDigest, Phase: delivery.PhaseRunning, - Health: delivery.HealthFailed, Detail: detail}) + raw, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: lemursDigest, Phase: nodeengineclient.PhaseRunning, + Health: nodeengineclient.HealthFailed, Detail: detail}) if err != nil { t.Fatal(err) } - hk, _ := delivery.HaveKey("n1", "lemurs") - sb.put(delivery.HaveBucket, hk, raw, rev) + hk, _ := nodeengineclient.HaveKey("n1", "lemurs") + sb.put(buckets.Have, hk, raw, rev) } lemursHave("its tool lemurs_health: no link to the bus is open", 10) drain() @@ -1273,9 +1275,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Errorf("a new revision's answer said %q", got) } settled = answering() - hk, _ := delivery.HaveKey("n1", "lemurs") - moved, _ := delivery.EncodeHave(delivery.Have{FP: fp, Composed: "sha256:" + strings.Repeat("e", 64), Phase: delivery.PhaseRunning}) - sb.put(delivery.HaveBucket, hk, moved, 13) + hk, _ := nodeengineclient.HaveKey("n1", "lemurs") + moved, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: "sha256:" + strings.Repeat("e", 64), Phase: nodeengineclient.PhaseRunning}) + sb.put(buckets.Have, hk, moved, 13) drain() if got := answering(); got != settled+len(answers) { t.Errorf("what runs moved, and %d answer(s) were composed again for %d group(s)", got-settled, len(answers)) @@ -1350,7 +1352,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { go func() { _ = nsh.Run(nctx) }() nb.made(6) nb.put(delivery.PlacementBucket, "placement.n1", tp, 1) - nb.put(delivery.HaveBucket, "n1.node", rec, 1) + nb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { raw, _ := delivery.EncodeModule(m) nb.put(delivery.ModulesBucket, name, raw, 1) @@ -1362,7 +1364,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { oldRaw, _ := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "older", Placement: &trunk}) oldKey, _ := delivery.PlacementCandidateKey("older", "n1") nb.put(delivery.PlacementCandidatesBucket, oldKey, oldRaw, 2) - for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, delivery.BuilderBuildsBucket} { nb.send(bucket, agent.Update{Caught: true}) } select { @@ -1508,7 +1510,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { cc.manifests[digest] = []byte(lemurs) cc.manifests[cdigest] = []byte(withCert) cc.modules = map[string]delivery.Module{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}} - cc.records["n1"] = delivery.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))} + cc.records["n1"] = nodeengine.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))} onlyLemurs := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}}} cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs) if certAsks != 0 { @@ -1656,7 +1658,7 @@ func (b *patternBus) caught() { // fixedReader is frombus's Reader over values held, for composing what a node runs. type fixedReader struct { placements map[string]delivery.Placement - records map[string]delivery.NodeRecord + records map[string]nodeengine.NodeRecord modules map[string]delivery.Module manifests map[string][]byte } @@ -1664,7 +1666,7 @@ type fixedReader struct { func (r fixedReader) Placements(context.Context) (map[string]delivery.Placement, error) { return r.placements, nil } -func (r fixedReader) Records(context.Context) (map[string]delivery.NodeRecord, error) { +func (r fixedReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { return r.records, nil } func (r fixedReader) Modules(context.Context) (map[string]delivery.Module, error) { @@ -1700,34 +1702,34 @@ func (r fixedReader) Served(context.Context, string, string, []string) (map[stri func TestAHaveSaysWhetherItsModuleRunsInsideTheNodesOwn(t *testing.T) { for _, row := range []struct { name string - have delivery.Have + have nodeengine.HaveByNodeModuleValue in bool // heldToday says the shadow counts the module held today (issue 1126: a seed holding it too holds it as today). heldToday bool }{ - {"failed, naming nothing it runs", delivery.Have{Phase: delivery.PhaseFailed, Health: delivery.HealthFailed, + {"failed, naming nothing it runs", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseFailed, Health: nodeengineclient.HealthFailed, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, false, true}, - {"failed at a build fingerprint", delivery.Have{FP: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: delivery.PhaseFailed, - Health: delivery.HealthFailed, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, true, true}, + {"failed at a build fingerprint", nodeengine.HaveByNodeModuleValue{Fp: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseFailed, + Health: nodeengineclient.HealthFailed, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, true, true}, // The live have of novox's de-spiegel (2026-10-28): a build of the old form, named by `build`, and no `fp`. - {"running a build of the old form", delivery.Have{Build: "build-1790368817085312877", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, true, true}, - {"running a build of the new form", delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64), - Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, false, false}, - {"running with no fingerprint (mesh-wireguard)", delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, true, false}, - {"held today", delivery.Have{FP: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: delivery.PhaseHeld, - Health: delivery.HealthWaits, Detail: "held: a required config value is unset"}, true, true}, + {"running a build of the old form", nodeengine.HaveByNodeModuleValue{Build: "build-1790368817085312877", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, true, true}, + {"running a build of the new form", nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64), + Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, false, false}, + {"running with no fingerprint (mesh-wireguard)", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, true, false}, + {"held today", nodeengine.HaveByNodeModuleValue{Fp: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseHeld, + Health: nodeengineclient.HealthWaits, Detail: "held: a required config value is unset"}, true, true}, // novox's postgres (2026-10-31), its have written before mesh-host #189: held by a backup the node agent's stop // interrupted and naming nothing; counted in, it was composed from its manifest and refused for its secret reader. - {"held by an interrupted backup, naming nothing", delivery.Have{Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, + {"held by an interrupted backup, naming nothing", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseHeld, Health: nodeengineclient.HealthWaits, Detail: agent.BackupInterrupted}, false, true}, } { t.Run(row.name, func(t *testing.T) { c := New(Env{Node: "n1"}) - key, err := delivery.HaveKey("n1", "web") + key, err := nodeengineclient.HaveKey("n1", "web") if err != nil { t.Fatal(err) } - value, err := delivery.EncodeHave(row.have) + value, err := nodeengineclient.EncodeHave(row.have) if err != nil { t.Fatal(err) } @@ -1783,16 +1785,16 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { } place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) - have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) b.put(t, delivery.PlacementBucket, "placement.n1", place) - b.put(t, delivery.HaveBucket, "n1.node", have) + b.put(t, buckets.Have, "n1.node", have) b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } handed := map[string]bool{} diff --git a/internal/selfcompose/shadow.go b/internal/selfcompose/shadow.go index 85a539d..c2d03b1 100644 --- a/internal/selfcompose/shadow.go +++ b/internal/selfcompose/shadow.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "strings" @@ -160,11 +162,11 @@ func (s *Shadow) Run(ctx context.Context) error { ins := []input{ {delivery.PlacementCandidatesBucket, ">", nil, nil}, {delivery.PlacementBucket, ">", (*Composer).takePlacement, nil}, - {delivery.HaveBucket, "*." + delivery.HaveNodeWord, nil, nil}, + {buckets.Have, "*." + nodeengineclient.NodeWord, nil, nil}, {delivery.ModulesBucket, ">", (*Composer).takeModule, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, // What this node runs moved: an apply ends in its module's have. - {delivery.HaveBucket, node + ".>", nil, nil}, + {buckets.Have, node + ".>", nil, nil}, } type tagged struct { in int @@ -315,7 +317,7 @@ func (s *Shadow) takeCandidate(u agent.Update) bool { // takeRecord takes a node's record, and says whether it changed: the node's own key changes with each answer written, // which moves nothing composed. func (s *Shadow) takeRecord(u agent.Update) bool { - node, _, isNode, err := delivery.ParseHaveKey(u.Key) + node, _, isNode, err := nodeengineclient.ParseHaveKey(u.Key) if err != nil || !isNode { return false } @@ -327,7 +329,7 @@ func (s *Shadow) takeRecord(u agent.Update) bool { c.mu.Lock() after, has := c.records[node] c.mu.Unlock() - return had != has || (has && !delivery.SameNodeRecord(before, after)) + return had != has || (has && !nodeengineclient.SameNodeRecord(before, after)) } // How long the shadow waits before it says it is not ready, and how long a proposal's answer runs before it is said slow. @@ -600,14 +602,14 @@ func (s *Shadow) heldToday() map[string]string { // takeRan keeps the digest one of this node's module haves says runs, and says whether it changed: a have whose health // or words alone moved runs the same. func (s *Shadow) takeRan(u agent.Update) bool { - node, module, isNode, err := delivery.ParseHaveKey(u.Key) + node, module, isNode, err := nodeengineclient.ParseHaveKey(u.Key) if err != nil || isNode || node != s.c.env.Node { return false } s.mu.Lock() defer s.mu.Unlock() before, had := s.ran[module] - h, err := delivery.DecodeHave(u.Value) + h, err := nodeengineclient.DecodeHave(u.Value) if s.heldNow == nil { s.heldNow = map[string]string{} } @@ -615,18 +617,18 @@ func (s *Shadow) takeRan(u agent.Update) bool { s.heldNow[module] = "" if !u.Deleted && err == nil { switch { - case h.Phase == delivery.PhaseHeld: + case h.Phase == nodeengineclient.PhaseHeld: s.heldNow[module] = "held" - case h.Phase == delivery.PhaseFailed: + case h.Phase == nodeengineclient.PhaseFailed: s.heldNow[module] = "failed" - case h.Phase != delivery.PhaseRemoved && h.FP == "" && h.Build != "": + case h.Phase != nodeengineclient.PhaseRemoved && h.Fp == "" && h.Build != "": // A build of the old form: its have names a `build` and no `fp`, which no registered build of the trunk // names, so the controller keeps it as it runs (issue 1126: novox's de-spiegel at build-…); a seed keeps it so. s.heldNow[module] = "runs a build of the old form" } } holdMoved := wasHeld != s.heldNow[module] // a hold's start or end changes the answer too - if u.Deleted || err != nil || h.Composed == "" || h.Phase == delivery.PhaseRemoved { + if u.Deleted || err != nil || h.Composed == "" || h.Phase == nodeengineclient.PhaseRemoved { delete(s.ran, module) return had || holdMoved } diff --git a/internal/userlist/userlist.go b/internal/userlist/userlist.go index b072aac..de4daa9 100644 --- a/internal/userlist/userlist.go +++ b/internal/userlist/userlist.go @@ -31,6 +31,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" engcomp "github.com/novox/mesh-host/internal/compositions" "regexp" "sort" @@ -49,7 +50,7 @@ const ResourceID = "bus-users" // Written is what the follower last did: the source it took the list from, the list's digest there, the file's digest // on the machine after the write, how many users the list holds, when, and why a write failed. -type Written = delivery.UserListWritten +type Written = nodeengine.UserListWritten // Env is everything the follower needs of the node. type Env struct { @@ -336,13 +337,13 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) { } moved := file.module != f.cur.module || pathOf(file.resource) != pathOf(f.cur.resource) f.cur = file - if !again && !moved && f.state.Source != "" && f.state.Revision == rev && f.written { + if !again && !moved && f.state.Source != "" && f.state.Revision == int64(rev) && f.written { f.mu.Unlock() return } f.mu.Unlock() - w := Written{Source: delivery.GrantsUserListSource, Revision: rev, Wanted: g.Digest, At: f.env.Now().UTC(), + w := Written{Source: delivery.GrantsUserListSource, Revision: int64(rev), Wanted: g.Digest, At: f.env.Now().UTC(), Follows: delivery.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()} var err error var changed bool @@ -370,7 +371,7 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) { } } if err == nil { - w.Users = Users(body) + w.Users = int64(Users(body)) if err = f.guard(resource, body); err != nil { if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil { w.Digest = Digest(disk) diff --git a/internal/userlist/userlist_test.go b/internal/userlist/userlist_test.go index 5ca5b44..7aad01e 100644 --- a/internal/userlist/userlist_test.go +++ b/internal/userlist/userlist_test.go @@ -109,7 +109,7 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { failWrite bool onDisk string wantWrites []string // contents written, in order - wantUsers int + wantUsers int64 wantErr string wantRefused bool grants []gput -- 2.54.0 From c35cc1d9e4cf5469637274dabafe59a160ba9015 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:34:02 +0100 Subject: [PATCH 40/73] =?UTF-8?q?Node-engine:=20the=20placement,=20the=20w?= =?UTF-8?q?ants=20and=20the=20placement=20candidates=20are=20the=20seats'?= =?UTF-8?q?=20contract=20types=20and=20the=20assigner=20client's=20keys=20?= =?UTF-8?q?and=20codecs;=20the=20found=20tunnel=20is=20the=20placement=20s?= =?UTF-8?q?eat's=20typed=20record=20(ADR=200634=20=C2=A71,=20=C2=A74;=20is?= =?UTF-8?q?sue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/nodeagent.go | 6 +- cmd/mesh-host/nodeagent_integration_test.go | 64 +++--- cmd/mesh-host/trust.go | 29 +-- cmd/mesh-host/whatruns_test.go | 7 +- go.mod | 3 + internal/agent/agent.go | 60 ++--- internal/agent/agent_test.go | 82 +++---- internal/agent/composition.go | 4 +- internal/buckets/buckets.go | 11 + internal/buckets/bucketstest/bucketstest.go | 36 +++ internal/compose/frombus/answer_rows_test.go | 25 ++- internal/compose/frombus/export.go | 36 +-- internal/compose/frombus/frombus.go | 46 ++-- internal/compose/frombus/frombus_test.go | 66 +++--- .../frombus/frombustest/frombustest.go | 11 +- .../frombus/frombustest/frombustest_test.go | 8 +- internal/compose/frombus/held_rows_test.go | 7 +- internal/compose/frombus/states.go | 16 +- internal/composer/composer.go | 24 +- internal/selfcompose/candidate.go | 5 +- internal/selfcompose/candidate_test.go | 14 +- internal/selfcompose/composedstate.go | 2 +- internal/selfcompose/composedstate_test.go | 22 +- internal/selfcompose/selfcompose.go | 67 +++--- internal/selfcompose/selfcompose_test.go | 206 +++++++++--------- internal/selfcompose/shadow.go | 22 +- 26 files changed, 489 insertions(+), 390 deletions(-) create mode 100644 internal/buckets/bucketstest/bucketstest.go diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index e44a938..6a9ff5b 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -8,6 +8,7 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" @@ -19,7 +20,6 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/compose/frombus" @@ -501,7 +501,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus }() env := agent.Env{ Node: node, - Wants: bucketWatch{session, delivery.AssignerWantsBucket}, + Wants: bucketWatch{session, buckets.Wants}, Composed: bucketWatch{session, composedBucket}, Haves: sessionHaves{session}, Kept: func() map[string][]json.RawMessage { by, _ := kept(); return by }, @@ -1017,7 +1017,7 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f func composerEnv(session *link.Session, node string, say func(string), hooks composerHooks) selfcompose.Env { env := selfcompose.Env{Node: node, Watch: func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) { - if bucket == delivery.PlacementBucket || bucket == delivery.PlacementCandidatesBucket { + if bucket == buckets.Placement || bucket == buckets.PlacementCandidates { // The seat's states are made when their holder starts: a node-engine that starts first waits for the // bus's word that the bucket was created, and the shadow and the composer say they wait. return bucketWatch{session, bucket}.WatchAwait(ctx, pattern, func(err error) { diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index c7f964b..9e6e94f 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -8,7 +8,11 @@ import ( "encoding/json" "errors" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets" + "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" @@ -136,7 +140,7 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed { name string into *nats.KeyValue history uint8 - }{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History}, + }{{buckets.Wants, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History}, {buckets.Have, &b.haves, buckets.HaveHistory}} { kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: c.name, History: c.history}) if err != nil { @@ -186,11 +190,11 @@ func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) } func (b *itBed) want(module, fp, digest, backup string) { b.t.Helper() - raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup}) + raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Composed: digest, Backup: backup}) if err != nil { b.t.Fatal(err) } - key, _ := delivery.WantKey(itNode, module) + key := meshwants.WantsByWantNodeModuleKey(itNode, module) if _, err := b.wants.Put(key, raw); err != nil { b.t.Fatal(err) } @@ -313,12 +317,12 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place [] if got := answering(); got != settled { t.Errorf("feat-x answered %d more time(s) at the same revision, with nothing it is composed from changed", got-settled) } - same, _ := delivery.DecodePlacement(place) - craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "feat-x", Placement: &same}) + same, _ := meshmoduleassignerclient.DecodePlacement(place) + craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Placement: &same}) if err != nil { t.Fatal(err) } - ckey, _ := delivery.PlacementCandidateKey("feat-x", itNode) + ckey := meshmoduleassigner.CandidatesByProposalNodeKey("feat-x", itNode) if _, err := candidates.Put(ckey, craw); err != nil { t.Fatal(err) } @@ -346,11 +350,11 @@ func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValu if n := node(); n.Composes != nec.ComposesOwn { t.Errorf("a node composing its own says it composes %q", n.Composes) } - craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "revert-x"}) + craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "revert-x"}) if err != nil { t.Fatal(err) } - ckey, _ := delivery.PlacementCandidateKey("revert-x", itNode) + ckey := meshmoduleassigner.CandidatesByProposalNodeKey("revert-x", itNode) if _, err := candidates.Put(ckey, craw); err != nil { t.Fatal(err) } @@ -360,7 +364,7 @@ func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValu strings.Contains(a.Refused, "composes nothing") }) ran := b.have("web").Composed - pkey, _ := delivery.PlacementNodeKey(itNode) + pkey, _ := meshmoduleassignerclient.NodeKey(itNode) // A pass in flight when the key goes (issue 1126, 2026-10-28): a config value changed starts one, and the key goes at once. if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil { t.Fatal(err) @@ -407,10 +411,10 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { } return kv } - placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket) + placement, modules, manifests := kvOf(buckets.Placement), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket) resolved := kvOf(selfcompose.ResolvedBucket) builds := kvOf(delivery.BuilderBuildsBucket) - candidates := kvOf(delivery.PlacementCandidatesBucket) + candidates := kvOf(buckets.PlacementCandidates) packages := kvOf(link.PackagesBucket) path := filepath.Join(b.dir, "web.conf") man := `{"module":"web","version":"1","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` + @@ -422,11 +426,11 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { mkey, _ := delivery.ManifestsKey(digest) fp := fpIt("a") modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)}) - place, _ := delivery.EncodePlacement(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}}) + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}) rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}}) // it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both. - pkey, _ := delivery.PlacementNodeKey(itNode) - places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}}) + pkey, _ := meshmoduleassignerclient.NodeKey(itNode) + places, _ := meshmoduleassignerclient.PlacementValues(meshmoduleassigner.Placement{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}) place = places[pkey] hkey, _ := nec.HaveNodeKey(itNode) // A provider on it2 serving from its own config value: a mail domain, which it1's composition settles. @@ -436,9 +440,9 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { mlraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "mailer", JSON: mailer}) mlkey, _ := delivery.ManifestsKey(mdigest) mlmod, _ := delivery.EncodeModule(delivery.Module{FP2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)}) - place2, _ := delivery.EncodePlacement(delivery.Placement{Node: "it2", Modules: []delivery.PlacedModule{{Module: "mailer"}}}) + place2, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "it2", Modules: []meshmoduleassigner.PlacedModule{{Module: "mailer"}}}) rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}}) - pkey2, _ := delivery.LegacyPlacementKey("it2") + pkey2, _ := meshmoduleassignerclient.LegacyKey("it2") hkey2, _ := nec.HaveNodeKey("it2") buildKey, _ := delivery.BuilderBuildKey("web", fp) done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest, @@ -459,8 +463,8 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { t.Fatal(err) } } - want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) - wkey, _ := delivery.WantKey(itNode, "web") + want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) + wkey := meshwants.WantsByWantNodeModuleKey(itNode, "web") if _, err := b.wants.Put(wkey, want); err != nil { t.Fatal(err) } @@ -536,12 +540,12 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str b.session.SayFacts(context.Background(), link.Report{Profile: map[string]any{"capabilities": []any{ map[string]any{"name": "service-manager", "present": true}}}}) second := b.have("web").Composed - same, _ := delivery.DecodePlacement(place) - craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "feat-x", Placement: &same}) + same, _ := meshmoduleassignerclient.DecodePlacement(place) + craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Placement: &same}) if err != nil { t.Fatal(err) } - ckey, _ := delivery.PlacementCandidateKey("feat-x", itNode) + ckey := meshmoduleassigner.CandidatesByProposalNodeKey("feat-x", itNode) crev, err := candidates.Put(ckey, craw) if err != nil { t.Fatal(err) @@ -761,10 +765,10 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test } return kv } - placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket) + placement, modules, manifests := kvOf(buckets.Placement), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket) kvOf(selfcompose.ResolvedBucket) builds := kvOf(delivery.BuilderBuildsBucket) - kvOf(delivery.PlacementCandidatesBucket) + kvOf(buckets.PlacementCandidates) kvOf(link.PackagesBucket) // The bus module at a build, done: its manifest written and the indexer's module naming it at fp. publish := func(fp, env string) { @@ -791,7 +795,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}}) hkey, _ := nec.HaveNodeKey(itNode) - places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "nats"}}}) + places, _ := meshmoduleassignerclient.PlacementValues(meshmoduleassigner.Placement{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "nats"}}}) // The have the engine wrote of the folded bus: the old-form build, no fingerprint. natsHave, _ := nec.HaveKey(itNode, "nats") oldHave, _ := nec.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1792674595610176744", Phase: nec.PhaseRunning, Health: nec.HealthOK}) @@ -800,12 +804,12 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test t.Fatal(err) } } - busWant := func(w delivery.Want) { - raw, err := delivery.EncodeWant(w) + busWant := func(w meshwants.WantsByWantNodeModuleValue) { + raw, err := bucketstest.Want(w) if err != nil { t.Fatal(err) } - key, _ := delivery.WantKey(itNode, "nats") + key := meshwants.WantsByWantNodeModuleKey(itNode, "nats") if _, err := b.wants.Put(key, raw); err != nil { t.Fatal(err) } @@ -837,7 +841,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test } first := fpIt("e") publish(first, "") - busWant(delivery.Want{FP: first, Self: true}) + busWant(meshwants.WantsByWantNodeModuleValue{Fp: first, Self: true}) stop := b.start() defer stop() for key, value := range places { @@ -869,11 +873,11 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test // Approve for a move the node judged to recreate it (ADR 0599 §2, §4). next := fpIt("f") publish(next, `,"env":{"CHANGED":"1"}`) - busWant(delivery.Want{FP: next, Self: true}) + busWant(meshwants.WantsByWantNodeModuleValue{Fp: next, Self: true}) within(t, 10*time.Second, "the build that changes the server was not applied", func() bool { h := b.have("nats") if h.Phase == nec.PhaseWaitingYes && h.Waits != "" { - busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits}) + busWant(meshwants.WantsByWantNodeModuleValue{Fp: next, Self: true, Yes: h.Waits}) } return h.Fp == next && h.Phase == nec.PhaseRunning }) diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 0f36859..74f7f10 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -9,6 +9,9 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "github.com/novox/mesh-host/internal/buckets" "os" "path/filepath" "strings" @@ -179,15 +182,15 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s // bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose placement or haves // cannot be read, keeps reading it. func placedAllHave(ctx context.Context, session *link.Session, node string) (bool, string) { - key, err := delivery.PlacementNodeKey(node) + key, err := meshmoduleassignerclient.NodeKey(node) if err != nil { return false, err.Error() } - legacy, _ := delivery.LegacyPlacementKey(node) + legacy, _ := meshmoduleassignerclient.LegacyKey(node) // Its node key names every module placed (research 072 row 37); before the holder converted it, its legacy key does. values := map[string][]byte{} for _, k := range []string{key, legacy} { - if h, err := session.KVHistory(ctx, delivery.PlacementBucket, k); err == nil && len(h) > 0 && !h[len(h)-1].Deleted { + if h, err := session.KVHistory(ctx, buckets.Placement, k); err == nil && len(h) > 0 && !h[len(h)-1].Deleted { values[k] = h[len(h)-1].Value } } @@ -202,25 +205,25 @@ func placedAllHave(ctx context.Context, session *link.Session, node string) (boo // placedModules is the modules node's node key names (a placement of their names alone), or its legacy key's placement // where it has no node key; an error when neither reads. -func placedModules(node string, values map[string][]byte) (delivery.Placement, error) { - key, _ := delivery.PlacementNodeKey(node) +func placedModules(node string, values map[string][]byte) (meshmoduleassigner.Placement, error) { + key, _ := meshmoduleassignerclient.NodeKey(node) if raw, ok := values[key]; ok { - n, err := delivery.DecodePlacementNode(raw) + n, err := meshmoduleassignerclient.DecodeNode(raw) if err != nil { - return delivery.Placement{}, err + return meshmoduleassigner.Placement{}, err } - p := delivery.Placement{Node: node} + p := meshmoduleassigner.Placement{Node: node} for _, r := range n.Modules { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: r.Module}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: r.Module}) } return p, nil } - legacy, _ := delivery.LegacyPlacementKey(node) + legacy, _ := meshmoduleassignerclient.LegacyKey(node) raw, ok := values[legacy] if !ok { - return delivery.Placement{}, errors.New("its placement is not read") + return meshmoduleassigner.Placement{}, errors.New("its placement is not read") } - return delivery.DecodePlacement(raw) + return meshmoduleassignerclient.DecodePlacement(raw) } // allHave says whether has finds, for every module p places, a have that names a fingerprint of the new form, and why @@ -228,7 +231,7 @@ func placedModules(node string, values map[string][]byte) (delivery.Placement, e // declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to // remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own // apply refused from then on). -func allHave(p delivery.Placement, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) { +func allHave(p meshmoduleassigner.Placement, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) { if len(p.Modules) == 0 { return false, "the seat mesh-placement places no module here" } diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 31e08ff..17067bc 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -7,6 +7,7 @@ import ( "errors" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" engcomp "github.com/novox/mesh-host/internal/compositions" "path/filepath" "sort" @@ -119,7 +120,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 { t.Fatalf("before the read is retired: %+v, %v", d, err) } - placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} + placed := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} // Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the // node's own) keeps the kept declaration read, since it is the one record of what runs of it. newForm := nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning} @@ -138,7 +139,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { } } // The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774). - withWireguard := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}} + withWireguard := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}} if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { if m == overlay.Name { return oldForm, true, nil @@ -147,7 +148,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { }); !got { t.Errorf("%s without a fingerprint beside zsh at the new form keeps the read: %s", overlay.Name, why) } - if got, _ := allHave(delivery.Placement{Node: "n1"}, nil); got { + if got, _ := allHave(meshmoduleassigner.Placement{Node: "n1"}, nil); got { t.Error("a node with nothing placed retires the read") } keptRetired.Store(true) diff --git a/go.mod b/go.mod index e5ee449..cbda85e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-20261105000000-000000000000 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index cda5919..80c13cb 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -19,6 +19,7 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "github.com/novox/mesh-host/internal/compositions" "slices" "sort" @@ -29,7 +30,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/judge" ) @@ -276,7 +276,7 @@ type againAsk struct { } type wantNow struct { - Want delivery.Want + Want meshwants.WantsByWantNodeModuleValue Node bool // the node's own want Present bool Rev uint64 @@ -751,26 +751,26 @@ func (a *Agent) Running() []compositions.Composed { func (a *Agent) takeWant(u Update) string { a.mu.Lock() defer a.mu.Unlock() - if _, err := delivery.ParseWantNodeKey(u.Key); err == nil { + if _, ok := meshwants.ParseWantsByWantNodeNodeKey(u.Key); ok { w := wantNow{Node: true, Rev: u.Revision} if !u.Deleted { - nw, err := delivery.DecodeNodeWant(u.Value) + nw, err := meshwants.DecodeWantsByWantNodeNodeValue(u.Value) if err != nil { a.sayOnce("want:node", fmt.Sprintf("the node's own want is not one: %v", err)) return "" } - w.Present, w.Want = true, delivery.Want{FP: "", Composed: nw.Composed} + w.Present, w.Want = true, meshwants.WantsByWantNodeModuleValue{Fp: "", Composed: nw.Composed} } a.wants[NodeModule] = w return NodeModule } - node, module, err := delivery.ParseWantKey(u.Key) - if err != nil || node != a.env.Node { + node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(u.Key) + if !ok || node != a.env.Node { return "" } w := wantNow{Rev: u.Revision} if !u.Deleted { - want, err := delivery.DecodeWant(u.Value) + want, err := meshwants.DecodeWantsByWantNodeModuleValue(u.Value) if err != nil { a.sayOnce("want:"+module, fmt.Sprintf("the want of %s is not one: %v", module, err)) return "" @@ -1136,8 +1136,8 @@ func (a *Agent) unreadableModules() []string { if w.Present && w.Want.Removed { continue } - if w.Present && w.Want.FP != "" { - if w.Want.FP == fp { + if w.Present && w.Want.Fp != "" { + if w.Want.Fp == fp { current[m] = pick{key, c} } continue @@ -1179,11 +1179,11 @@ func (a *Agent) heldOut(except string) []compositions.Composed { if w := a.wants[m]; w.Present && w.Want.Removed { continue // a module the assigner removed holds nothing back } - if w := a.wants[m]; w.Present && !w.Want.Removed && w.Want.FP == fp { + if w := a.wants[m]; w.Present && !w.Want.Removed && w.Want.Fp == fp { waiting[m] = c continue } - if w := a.wants[m]; w.Present && !w.Want.Removed && waiting[m].Rev != 0 && waiting[m].C.FP == w.Want.FP { + if w := a.wants[m]; w.Present && !w.Want.Removed && waiting[m].Rev != 0 && waiting[m].C.FP == w.Want.Fp { continue } if prev, ok := waiting[m]; !ok || c.Rev > prev.Rev { @@ -1283,7 +1283,7 @@ func (a *Agent) RetryFailed(module string) error { return fmt.Errorf("%s has no want on this machine, so there is no apply of it to try again", module) case tried && att.Held: return fmt.Errorf("%s is held for a failed backup: the operator's answer releases it", module) - case !tried && ran && r.Fp == w.Want.FP && r.Phase != nodeengineclient.PhaseRemoved: + case !tried && ran && r.Fp == w.Want.Fp && r.Phase != nodeengineclient.PhaseRemoved: return fmt.Errorf("%s runs what its want names, nothing to retry", module) case !tried: return fmt.Errorf("%s has no failed apply to try again: it waits for its composition, or settles on its own", module) @@ -1331,7 +1331,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) { return } a.mu.Lock() - comp, have := a.compositionFor(module, w.Want.FP) + comp, have := a.compositionFor(module, w.Want.Fp) a.mu.Unlock() if a.env.SelfComposed { if !have || comp.Err != nil { @@ -1345,7 +1345,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) { } else if (!have || comp.Err != nil || comp.Dig != w.Want.Composed) && w.Want.Composed != "" { // The composition the want names may be an older revision of the key, replaced by a newer one at the same // fingerprint (a want held there keeps its digest): taken from the revisions the bucket keeps (ADR 0539 clause 1). - if old, found := a.revisionNamed(ctx, module, w.Want.FP, w.Want.Composed); found { + if old, found := a.revisionNamed(ctx, module, w.Want.Fp, w.Want.Composed); found { comp, have = old, true } } @@ -1356,7 +1356,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) { // A composition whose digest no want names is not applied. a.mu.Lock() a.sayOnce("digest:"+module, fmt.Sprintf("the composition of %s at %s has digest %s, and its want names %s; nothing is applied until they agree", - module, w.Want.FP, comp.Dig, w.Want.Composed)) + module, w.Want.Fp, comp.Dig, w.Want.Composed)) a.mu.Unlock() return } @@ -1372,19 +1372,19 @@ func (a *Agent) settleOne(ctx context.Context, module string) { } a.mu.Lock() r, runs := a.running[module] - runsWant := runs && r.Fp == w.Want.FP && r.Composed == w.Want.Composed && r.Phase != nodeengineclient.PhaseRemoved + runsWant := runs && r.Fp == w.Want.Fp && r.Composed == w.Want.Composed && r.Phase != nodeengineclient.PhaseRemoved att, tried := a.attempt[module] - same := tried && att.FP == w.Want.FP && att.Composed == w.Want.Composed + same := tried && att.FP == w.Want.Fp && att.Composed == w.Want.Composed if same && att.Held { // Held for a failed backup: looked at before what runs, since a hold may name what the want names (its have kept // the composition it held, nats on novox, 2026-11-04), and an answer in a later revision of the want would then // be swallowed by "it runs what its want names" (issue 1585, the second failure). The operator's answer, again or // skip, in a revision after the one the hold took, is acted on whatever runs; any other new revision of the want // is the answer only for a move (ADR 0568 §4), as before. - answered := w.Rev > att.AnsweredRev && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip) + answered := w.Rev > att.AnsweredRev && (w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupAgain || w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupSkip) if !answered && (runsWant || (att.AnsweredRev >= w.Rev && att.CompRev == comp.Rev)) { a.sayOnce("held:"+module, fmt.Sprintf("%s is held for a failed backup before %s: it waits for the operator's "+ - "answer, and its want (revision %d) carries none since the hold (revision %d)", module, w.Want.FP, w.Rev, att.AnsweredRev)) + "answer, and its want (revision %d) carries none since the hold (revision %d)", module, w.Want.Fp, w.Rev, att.AnsweredRev)) a.mu.Unlock() return } @@ -1447,7 +1447,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com a.mu.Lock() a.prior[module] = true a.mu.Unlock() - if h.Phase == string(nodeengineclient.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != delivery.BackupSkip { + if h.Phase == string(nodeengineclient.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != meshwants.WantsByWantNodeModuleValueBackupSkip { // Held only because this node agent stopped while the backup ran (postgres on novox, 2026-10-31): no // failure and nothing for the operator to answer. It is backed up again and applied (a.prior makes the // apply back it up first); the want still asking a backup is all the answer needed. @@ -1456,7 +1456,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com a.mu.Unlock() return false } - if h.Phase == string(nodeengineclient.PhaseHeld) && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip) { + if h.Phase == string(nodeengineclient.PhaseHeld) && (w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupAgain || w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupSkip) { // Held for a failed backup, and the want already carries the operator's answer, written before this engine // started (nats on novox, 2026-11-04: "Back up again" answered, then the engine restarted and took the // answer for the hold's own revision, so it was never acted on; issue 1585). The answer is acted on now: @@ -1477,7 +1477,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com a.sayOnce("held:"+module, fmt.Sprintf("%s is held for a failed backup (%s) at this start: it keeps what "+ "its have names, %s, and waits for the operator's answer; its want (revision %d) carries none", module, h.Detail, short(h.Composed), w.Rev)) - a.attempt[module] = attempt{FP: w.Want.FP, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true, + a.attempt[module] = attempt{FP: w.Want.Fp, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev} if h.Composed != "" { a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed, Phase: nodeengineclient.PhaseRunning} @@ -1496,7 +1496,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com if marked { h.Detail = "" } - if h.FP == w.Want.FP && h.Composed == w.Want.Composed && + if h.FP == w.Want.Fp && h.Composed == w.Want.Composed && (h.Phase == "" || h.Phase == string(nodeengineclient.PhaseRunning) || marked) { if a.inactiveAtStart(ctx, module, comp) { return false @@ -1531,10 +1531,10 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com return false } a.mu.Lock() - a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: w.Want.FP, Composed: w.Want.Composed, Phase: nodeengineclient.PhaseRunning} + a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: w.Want.Fp, Composed: w.Want.Composed, Phase: nodeengineclient.PhaseRunning} a.mu.Unlock() a.runsChanged(module + " was taken from the kept declaration") - a.writeHave(ctx, Have{Module: module, FP: w.Want.FP, Composed: w.Want.Composed, Phase: string(nodeengineclient.PhaseRunning)}) + a.writeHave(ctx, Have{Module: module, FP: w.Want.Fp, Composed: w.Want.Composed, Phase: string(nodeengineclient.PhaseRunning)}) return true } @@ -1553,7 +1553,7 @@ func (a *Agent) inactiveAtStart(ctx context.Context, module string, comp compNow } func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNow, others []compositions.Composed) { - fp, dig := w.Want.FP, w.Want.Composed + fp, dig := w.Want.Fp, w.Want.Composed a.mu.Lock() _, ranBefore := a.running[module] ranBefore = ranBefore || a.prior[module] @@ -1561,7 +1561,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo // A data module is backed up before a new composition of it moves (ADR 0429 §14), unless the want says to go on // without one (ADR 0539 §16). - if comp.C.Data && ranBefore && a.env.Backup != nil && w.Want.Backup != delivery.BackupSkip { + if comp.C.Data && ranBefore && a.env.Backup != nil && w.Want.Backup != meshwants.WantsByWantNodeModuleValueBackupSkip { // What runs, read before the have says backing-up: a module known only from its have (a.prior) has no running // entry, and its held have kept naming nothing (postgres on novox, 2026-10-31), as the not-composed mark keeps it. a.mu.Lock() @@ -1811,11 +1811,11 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp return false } a.mu.Lock() - a.sayOnce("yes:"+module+":"+comp.Dig, fmt.Sprintf("%s at %s waits for a person's yes: %s", module, w.Want.FP, j.Words)) + a.sayOnce("yes:"+module+":"+comp.Dig, fmt.Sprintf("%s at %s waits for a person's yes: %s", module, w.Want.Fp, j.Words)) prev := a.running[module] a.mu.Unlock() a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseWaitingYes), - Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.FP)}) + Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.Fp)}) return true } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 2d205b4..8627176 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -6,6 +6,8 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" "maps" "slices" @@ -254,11 +256,8 @@ func encodedAs(t *testing.T, c compositions.Composed, key string) (string, []byt func wantFor(t *testing.T, module, fp, digest, backup string) (string, []byte) { t.Helper() - key, err := delivery.WantKey(node, module) - if err != nil { - t.Fatal(err) - } - raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup}) + key := meshwants.WantsByWantNodeModuleKey(node, module) + raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Composed: digest, Backup: backup}) if err != nil { t.Fatal(err) } @@ -684,7 +683,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { } // The answer arrives in the module's own want: back up again (it fails again), then skip. - wk, again := wantFor(t, "db", fp2, dig2, delivery.BackupAgain) + wk, again := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupAgain) b.wants.put(wk, again) eventually(t, "back up again did not ask the backup", func() bool { b.mu.Lock() @@ -694,7 +693,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { if got := b.appliedList(); len(got) != 0 { t.Fatalf("applied behind a second failed backup: %v", got) } - _, skip := wantFor(t, "db", fp2, dig2, delivery.BackupSkip) + _, skip := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupSkip) b.wants.put(wk, skip) eventually(t, "skip did not apply the module", func() bool { return len(b.appliedList()) == 1 }) b.mu.Lock() @@ -713,7 +712,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { ib := newBed(t) ib.comps.before(ck1, craw1) ib.comps.before(ck2, craw2) - iwk, iwraw := wantFor(t, "db", fp2, dig2, delivery.BackupAgain) + iwk, iwraw := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupAgain) ib.wants.before(iwk, iwraw) ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseRunning)}} began := make(chan struct{}) @@ -738,13 +737,13 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { phase string namesNothing bool // the have names no fp and no composition }{ - {BackupInterrupted, delivery.BackupAgain, 1, 1, nodeengineclient.PhaseRunning, false}, + {BackupInterrupted, meshwants.WantsByWantNodeModuleValueBackupAgain, 1, 1, nodeengineclient.PhaseRunning, false}, // novox's postgres: its held have, written before mesh-host #189, names nothing it runs. {BackupInterrupted, "", 1, 1, nodeengineclient.PhaseRunning, true}, // A backup that failed, and the want carries the operator's answer, written before this start (nats on novox, // 2026-11-04, issue 1585): again backs it up and applies, skip applies without one; no answer keeps the hold. - {"restic: the repository is locked", delivery.BackupAgain, 1, 1, nodeengineclient.PhaseRunning, false}, - {"restic: the repository is locked", delivery.BackupSkip, 0, 1, nodeengineclient.PhaseRunning, false}, + {"restic: the repository is locked", meshwants.WantsByWantNodeModuleValueBackupAgain, 1, 1, nodeengineclient.PhaseRunning, false}, + {"restic: the repository is locked", meshwants.WantsByWantNodeModuleValueBackupSkip, 0, 1, nodeengineclient.PhaseRunning, false}, {"restic: the repository is locked", "", 0, 0, nodeengineclient.PhaseHeld, false}, } { r := newBed(t) @@ -783,9 +782,9 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { backups int holdsTill bool // it is held, and said held, before the later revision }{ - {"answered before the start", delivery.BackupAgain, "", 1, false}, - {"answered while held", "", delivery.BackupAgain, 1, true}, - {"skipped while held", "", delivery.BackupSkip, 0, true}, + {"answered before the start", meshwants.WantsByWantNodeModuleValueBackupAgain, "", 1, false}, + {"answered while held", "", meshwants.WantsByWantNodeModuleValueBackupAgain, 1, true}, + {"skipped while held", "", meshwants.WantsByWantNodeModuleValueBackupSkip, 0, true}, } { t.Run(row.name, func(t *testing.T) { r := newBed(t) @@ -793,9 +792,9 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) { r.env.Policy = func(context.Context, string) string { return catalogue.PolicyRecord } ck, craw, dig := composition(t, "nats", fp2, true, file("nats", "x", "2")) r.comps.before(ck, craw) - wk, _ := delivery.WantKey(node, "nats") + wk := meshwants.WantsByWantNodeModuleKey(node, "nats") selfWant := func(backup string) []byte { - raw, err := delivery.EncodeWant(delivery.Want{FP: fp2, Self: true, Yes: dig, Backup: backup}) + raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp2, Self: true, Yes: dig, Backup: backup}) if err != nil { t.Fatal(err) } @@ -888,7 +887,7 @@ func TestARemovedWantRemovesThatModuleOnly(t *testing.T) { wk, wraw := wantFor(t, "web", fp, dig, "") b.wants.put(wk, wraw) eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 }) - gone, _ := delivery.EncodeWant(delivery.Want{Removed: true}) + gone, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true}) b.wants.put(wk, gone) eventually(t, "not removed", func() bool { b.mu.Lock() @@ -909,11 +908,8 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) { ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1")) wk, wraw := wantFor(t, "web", fp, dig, "") nk, nraw, ndig := composition(t, NodeModule, "", false) - nwk, err := delivery.WantNodeKey(node) - if err != nil { - t.Fatal(err) - } - nwraw, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig}) + nwk := meshwants.WantsByWantNodeNodeKey(node) + nwraw, err := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig}) if err != nil { t.Fatal(err) } @@ -1035,11 +1031,11 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { ck, _ := compositions.Key(node, "web", fpOf("b")) raw2, _ := compositions.Encode(web2) comps.put(ck, raw2) - wk, _ := delivery.WantKey(node, "web") - wraw, _ := delivery.EncodeWant(delivery.Want{FP: fpOf("b"), Self: true}) + wk := meshwants.WantsByWantNodeModuleKey(node, "web") + wraw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpOf("b"), Self: true}) b.wants.put(wk, wraw) eventually(t, "the applied composition was not kept", func() bool { return string(store.get("web")) == string(raw2) }) - rraw, _ := delivery.EncodeWant(delivery.Want{Removed: true}) + rraw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true}) b.wants.put(wk, rraw) eventually(t, "the removed module's composition was not forgotten", func() bool { return store.get("web") == nil }) }) @@ -1182,11 +1178,8 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing. rk, rraw, _ := composition(t, "systemd-resolved", fp, false, file("systemd-resolved", "guard", "g")) b.comps.before(rk, rraw) nk, nraw, ndig := composition(t, NodeModule, "", false) - nwk, err := delivery.WantNodeKey(node) - if err != nil { - t.Fatal(err) - } - nwraw, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig}) + nwk := meshwants.WantsByWantNodeNodeKey(node) + nwraw, err := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig}) if err != nil { t.Fatal(err) } @@ -1216,7 +1209,7 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing. spiegel, _ = json.Marshal(bare) b2.env.Kept = func() map[string][]json.RawMessage { return map[string][]json.RawMessage{NodeModule: {spiegel}} } nk2, nraw2, ndig2 := composition(t, NodeModule, "", false) - nwraw2, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig2}) + nwraw2, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig2}) b2.comps.before(nk2, nraw2) b2.wants.before(nwk, nwraw2) ctx2, cancel2 := context.WithCancel(context.Background()) @@ -1244,18 +1237,15 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) { fp := fpOf("b") rk, rraw, _ := composition(t, "gone", fp, false, file("gone", "x", "1")) b.comps.before(rk, rraw) - gk, err := delivery.WantKey(node, "gone") - if err != nil { - t.Fatal(err) - } - graw, err := delivery.EncodeWant(delivery.Want{Removed: true}) + gk := meshwants.WantsByWantNodeModuleKey(node, "gone") + graw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true}) if err != nil { t.Fatal(err) } b.wants.before(gk, graw) nk, nraw, ndig := composition(t, NodeModule, "", false) - nwk, _ := delivery.WantNodeKey(node) - nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig}) + nwk := meshwants.WantsByWantNodeNodeKey(node) + nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig}) b.comps.before(nk, nraw) b.wants.before(nwk, nwraw) stop := b.run() @@ -1289,8 +1279,8 @@ func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) { } b.comps.before(bad, []byte(`{"v":1,"not":"a composition"}`)) nk, nraw, ndig := composition(t, NodeModule, "", false) - nwk, _ := delivery.WantNodeKey(node) - nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig}) + nwk := meshwants.WantsByWantNodeNodeKey(node) + nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig}) b.comps.before(nk, nraw) b.wants.before(nwk, nwraw) stop := b.run() @@ -1327,8 +1317,8 @@ func TestAStaleRefusedCompositionDoesNotHoldTheNodesOwnBack(t *testing.T) { gk, graw, _ := composition(t, "power", fpOf("b"), false, file("power", "x", "1")) b.comps.before(gk, graw) nk, nraw, ndig := composition(t, NodeModule, "", false) - nwk, _ := delivery.WantNodeKey(node) - nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig}) + nwk := meshwants.WantsByWantNodeNodeKey(node) + nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig}) b.comps.before(nk, nraw) b.wants.before(nwk, nwraw) stop := b.run() @@ -1539,8 +1529,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( return key, raw, dig } want := func(module, fp, yes string) { - key, _ := delivery.WantKey(node, module) - raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Yes: yes, Self: true}) + key := meshwants.WantsByWantNodeModuleKey(node, module) + raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Yes: yes, Self: true}) if err != nil { t.Fatal(err) } @@ -1574,8 +1564,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( nraw, _ := compositions.Encode(own) ndig, _ := compositions.DigestOf(own) b.comps.put(nk, nraw) - nwk, _ := delivery.WantNodeKey(node) - nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) + nwk := meshwants.WantsByWantNodeNodeKey(node) + nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) b.wants.put(nwk, nwraw) eventually(t, "the node's own key does not carry its sealed values", func() bool { h := b.haves.last(NodeModule) diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 48e3bd2..d626fb9 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -156,8 +156,8 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo w := a.wants[module] fp := "" if w.Present { - fp = w.Want.FP - view.Want = &WantView{FP: w.Want.FP, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed, + fp = w.Want.Fp + view.Want = &WantView{FP: w.Want.Fp, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed, Self: w.Want.Self, Backup: w.Want.Backup, Words: w.Want.Words, Revision: w.Rev} } else if have != nil { fp = have.FP diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index ae8c8ac..878f4a9 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -15,3 +15,14 @@ const ( // HaveHistory is the number of revisions the have bucket keeps per key. const HaveHistory = 20 + +// The states of the placement seat's holder and of the wants seat's holder that the engine reads, named by the holder's +// module as every state is (ADR 0634 §1, finding F4: the holder's name is kept here, in the engine's code). +const ( + // Placement holds a node's placement, `.node`, and each of its modules, `.`. + Placement = "mesh-delivery-assigner_placement" + // PlacementCandidates holds a node's candidate of a proposal, `.`. + PlacementCandidates = "mesh-delivery-assigner_candidates" + // Wants holds what each node should run, `want..` and `want..node`. + Wants = "mesh-delivery-assigner_wants" +) diff --git a/internal/buckets/bucketstest/bucketstest.go b/internal/buckets/bucketstest/bucketstest.go new file mode 100644 index 0000000..9848e23 --- /dev/null +++ b/internal/buckets/bucketstest/bucketstest.go @@ -0,0 +1,36 @@ +// Package bucketstest writes the values of the states the engine only reads (a want, a placement candidate) as their +// holders do, for the tests that stand in for the holders. +package bucketstest + +import ( + "encoding/json" + + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" +) + +// Version is the value version the holders write. +const Version = 1 + +// Want is a module's want as the wants seat's holder writes it. +func Want(w meshwants.WantsByWantNodeModuleValue) ([]byte, error) { + w.V = Version + return json.Marshal(w) +} + +// NodeWant is a node's own want as the wants seat's holder writes it. +func NodeWant(w meshwants.WantsByWantNodeNodeValue) ([]byte, error) { + w.V = Version + return json.Marshal(w) +} + +// Candidate is a node's candidate of a proposal as the placement seat's holder writes it. +func Candidate(c meshmoduleassigner.CandidatesByProposalNodeValue) ([]byte, error) { + c.V = Version + if c.Placement != nil { + p := *c.Placement + p.V = Version + c.Placement = &p + } + return json.Marshal(c) +} diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 784624e..e1e2747 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -6,6 +6,7 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -38,11 +39,11 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } mk := func(placed ...string) *bus { - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} for _, m := range placed { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m}) } - b := &bus{placements: map[string]delivery.Placement{"n1": p}, + b := &bus{placements: map[string]meshmoduleassigner.Placement{"n1": p}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, @@ -66,9 +67,9 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } return b } - given := func(b *bus) map[string]*delivery.Placement { + given := func(b *bus) map[string]*meshmoduleassigner.Placement { p := b.placements["n1"] - return map[string]*delivery.Placement{"n1": &p} + return map[string]*meshmoduleassigner.Placement{"n1": &p} } // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named. @@ -238,7 +239,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { p.Decided.Endpoint = "n1.example:51820" for i := range p.Modules { if p.Modules[i].Module == "spiegel" { - p.Modules[i].Ports = map[string]int{"8080": 20005} + p.Modules[i].Ports = map[string]int64{"8080": 20005} } } b.placements["n1"] = p @@ -476,7 +477,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { p.Decided.Endpoint = "n1.example:51820" for i := range p.Modules { if p.Modules[i].Module == "site" { - p.Modules[i].Ports = map[string]int{"8080": 20005} + p.Modules[i].Ports = map[string]int64{"8080": 20005} } } b.placements["n1"] = p @@ -517,7 +518,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { p2.Decided.Endpoint = "n1.example:51820" for i := range p2.Modules { if p2.Modules[i].Module == "site" { - p2.Modules[i].Ports = map[string]int{"8080": 20005} + p2.Modules[i].Ports = map[string]int64{"8080": 20005} } } b2.placements["n1"] = p2 @@ -588,13 +589,13 @@ func otherNodeNotResolvedIsNamed(t *testing.T) { prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`) cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`) // n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here. - b := &bus{placements: map[string]delivery.Placement{ - "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, - "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, + b := &bus{placements: map[string]meshmoduleassigner.Placement{ + "n1": {Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, + "n2": {Node: "n2", Decided: meshmoduleassigner.NodeDecided{Address: "10.77.0.2"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} - given := map[string]*delivery.Placement{} + given := map[string]*meshmoduleassigner.Placement{} for n, p := range b.placements { p := p given[n] = &p diff --git a/internal/compose/frombus/export.go b/internal/compose/frombus/export.go index 432e7fb..7d82bf1 100644 --- a/internal/compose/frombus/export.go +++ b/internal/compose/frombus/export.go @@ -2,12 +2,12 @@ package frombus import ( "fmt" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "sort" "strconv" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" ) @@ -16,7 +16,7 @@ import ( // answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay // stable for the same store, since adopt checks its digest again at the warrant. // What the machine observes (its record) is not in it. -func Export(in compose.Input, node string) (delivery.Placement, error) { +func Export(in compose.Input, node string) (meshmoduleassigner.Placement, error) { var n *compose.Node for i := range in.Nodes { if in.Nodes[i].Name == node { @@ -24,14 +24,14 @@ func Export(in compose.Input, node string) (delivery.Placement, error) { } } if n == nil { - return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) + return meshmoduleassigner.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) } // At the version a placement key is read at, with a list of modules even when none is placed: the export is the // seed's value, and the reader refuses a value of version 0 or without its modules. - p := delivery.Placement{V: sdkkeys.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ + p := meshmoduleassigner.Placement{V: sdkkeys.Version, Node: node, Modules: []meshmoduleassigner.PlacedModule{}, Decided: meshmoduleassigner.NodeDecided{ Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, - AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} + AgentAccountHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} // The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted // one whether or not the hub took it; and the names a person gave the hub's carried peers. if t, ok := in.Tunnels[node]; ok { @@ -52,27 +52,27 @@ func Export(in compose.Input, node string) (delivery.Placement, error) { } } for _, module := range n.Assigned { - m := delivery.PlacedModule{Module: module} + m := meshmoduleassigner.PlacedModule{Module: module} for port, at := range n.Ports[module] { if m.Ports == nil { - m.Ports = map[string]int{} + m.Ports = map[string]int64{} } - m.Ports[strconv.Itoa(port)] = at + m.Ports[strconv.Itoa(port)] = int64(at) } for provision, to := range n.Bound[module] { if m.Bindings == nil { - m.Bindings = map[string]delivery.Provider{} + m.Bindings = map[string]meshmoduleassigner.Provider{} } - m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + m.Bindings[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module} } // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with // the placement. A module in the config form has mesh-config's values laid into its layer, which are not. settingsForm := !in.Catalogue[module].DeclaresConfig() for _, layer := range n.Settings[module] { if layer.From == catalogue.MeshWideLayer { - m.HasMesh, m.MeshWide = true, layer.Placement + m.HasMeshWide, m.MeshWide = true, layer.Placement if settingsForm && len(layer.Values) > 0 { - m.MeshValues = layer.Values + m.MeshWideValues = layer.Values } } else { m.HasLayer, m.Layer = true, layer.Placement @@ -85,13 +85,13 @@ func Export(in compose.Input, node string) (delivery.Placement, error) { } for provision, to := range n.Pins { if p.Pins == nil { - p.Pins = map[string]delivery.Provider{} + p.Pins = map[string]meshmoduleassigner.Provider{} } - p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + p.Pins[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module} } for _, h := range in.Holdings { if h.Node == node { - p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) + p.Holds = append(p.Holds, meshmoduleassigner.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) } } sort.SliceStable(p.Holds, func(i, j int) bool { @@ -103,11 +103,11 @@ func Export(in compose.Input, node string) (delivery.Placement, error) { return p, nil } -func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel { - out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, +func recordTunnelOf(t compose.Tunnel) meshmoduleassigner.FoundTunnel { + out := meshmoduleassigner.FoundTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: int64(t.Port), Mtu: int64(t.MTU), Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} for _, peer := range t.Peers { - out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address}) + out.Peers = append(out.Peers, meshmoduleassigner.FoundTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address}) } return out } diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 557d842..27030e9 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -36,6 +36,8 @@ import ( "errors" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "net/netip" @@ -55,8 +57,8 @@ import ( // Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a // read that failed, never an absent key (an absent key is an empty answer). type Reader interface { - // Placements is every node's placement, by node (delivery.PlacementBucket). - Placements(ctx context.Context) (map[string]delivery.Placement, error) + // Placements is every node's placement, by node (buckets.Placement). + Placements(ctx context.Context) (map[string]meshmoduleassigner.Placement, error) // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of // buckets.Have (ADR 0555 §3). Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error) @@ -133,7 +135,7 @@ type Mesh struct { // read it: the check of every node judges placement, and reads no archive. // // Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates -// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with +// (meshmoduleassigner.CandidatesByProposalNodeValue), so a node composes in shadow with a proposal laid over and compares the result with // what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always. // // InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again @@ -146,7 +148,7 @@ type Scope struct { Node string Every bool KeptAt map[string]string - Placements map[string]*delivery.Placement + Placements map[string]*meshmoduleassigner.Placement InOwn []string Running []engcomp.Composed // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with @@ -167,7 +169,7 @@ type Scope struct { // controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module // run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own // composition keeps them as it runs them until a want names a build of the new form for one. -func InOwn(running []engcomp.Composed, p delivery.Placement) []string { +func InOwn(running []engcomp.Composed, p meshmoduleassigner.Placement) []string { placed := map[string]bool{} for _, m := range p.Modules { placed[m.Module] = true @@ -252,7 +254,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module // for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice). type base struct { in compose.Input - placements map[string]delivery.Placement + placements map[string]meshmoduleassigner.Placement records map[string]nodeengine.NodeRecord modules map[string]delivery.Module // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order @@ -292,7 +294,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { return b, fmt.Errorf("frombus: placement: %w", err) } if len(s.Placements) > 0 { - given := make(map[string]delivery.Placement, len(placements)+len(s.Placements)) + given := make(map[string]meshmoduleassigner.Placement, len(placements)+len(s.Placements)) for node, p := range placements { given[node] = p } @@ -419,7 +421,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { strings.Join(keys, ", "), err) } } - if layers := m.Layers(node, values); layers != nil { + if layers := meshmoduleassignerclient.Layers(m, node, values); layers != nil { if n.Settings == nil { n.Settings = map[string][]catalogue.Layer{} } @@ -468,7 +470,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { } } in.Nodes = append(in.Nodes, n) - in.Holdings = append(in.Holdings, p.Held()...) + in.Holdings = append(in.Holdings, meshmoduleassignerclient.Held(p)...) // A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the // controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over // (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers). @@ -792,7 +794,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, // by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it // (issue 1126). func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), - in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module, + in *compose.Input, s Scope, p meshmoduleassigner.Placement, modules map[string]delivery.Module, read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), refuse func(module string, err error), wanted map[string]string) error { // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides @@ -999,7 +1001,7 @@ func attachedOf(in []nodeengine.AttachedNetwork) []catalogue.AttachedNetwork { // nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person // decided of it, and where its modules run). -func nodeOf(rec nodeengine.NodeRecord, p delivery.Placement) compose.Node { +func nodeOf(rec nodeengine.NodeRecord, p meshmoduleassigner.Placement) compose.Node { n := compose.Node{ Name: rec.Node, Place: compose.Place{Key: rec.TunnelKey}, @@ -1019,13 +1021,13 @@ func nodeOf(rec nodeengine.NodeRecord, p delivery.Placement) compose.Node { // placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes // (its keys, links, networks, certificate) and its settings layers are left as they are. -func placeNode(n *compose.Node, p delivery.Placement) { +func placeNode(n *compose.Node, p meshmoduleassigner.Placement) { d := p.Decided n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address} - n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain - n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome + n.Assigned, n.PublicDomain = meshmoduleassignerclient.Assigned(p), d.PublicDomain + n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentAccountHome n.Adopted, n.Taken = d.Adopted, d.Taken - n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts() + n.Pins, n.Bound, n.Ports = meshmoduleassignerclient.ChosenPins(p), meshmoduleassignerclient.Bound(p), meshmoduleassignerclient.HostPorts(p) } // Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's @@ -1035,7 +1037,7 @@ func placeNode(n *compose.Node, p delivery.Placement) { // no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given // placement. It is how a check composes a change's own placement over an input already filled (composer.Case's // Placements). -func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) { +func Placed(in compose.Input, placements map[string]*meshmoduleassigner.Placement) (compose.Input, error) { if len(placements) == 0 { return in, nil } @@ -1052,7 +1054,7 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node) } if p == nil { - p = &delivery.Placement{Node: node} + p = &meshmoduleassigner.Placement{Node: node} } if p.Node != node { return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) @@ -1067,7 +1069,7 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos values = l.Values } } - if layers := m.Layers(node, values); layers != nil { + if layers := meshmoduleassignerclient.Layers(m, node, values); layers != nil { settings[m.Module] = layers } } @@ -1085,14 +1087,14 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos } for _, node := range sortedKeys(placements) { if p := placements[node]; p != nil { - out.Holdings = append(out.Holdings, p.Held()...) + out.Holdings = append(out.Holdings, meshmoduleassignerclient.Held(*p)...) } } return out, nil } -func tunnelOf(t delivery.RecordTunnel) compose.Tunnel { - out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, +func tunnelOf(t meshmoduleassigner.FoundTunnel) compose.Tunnel { + out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: int(t.Port), MTU: int(t.Mtu), Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} for _, p := range t.Peers { out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address}) @@ -1126,7 +1128,7 @@ func carriedPeers(t compose.Tunnel, names map[string]string, records map[string] } // nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. -func nodesOf(placements map[string]delivery.Placement, records map[string]nodeengine.NodeRecord) []string { +func nodesOf(placements map[string]meshmoduleassigner.Placement, records map[string]nodeengine.NodeRecord) []string { seen := map[string]bool{} for n := range placements { seen[n] = true diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index efd0e77..8b112ba 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -13,6 +13,8 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -31,7 +33,7 @@ import ( type bus struct { vaultAuthority string handed map[string]bool - placements map[string]delivery.Placement + placements map[string]meshmoduleassigner.Placement records map[string]nodeengine.NodeRecord modules map[string]delivery.Module manifests map[string][]byte @@ -79,7 +81,7 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map return out, nil } -func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { +func (b *bus) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { return b.placements, nil } func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } @@ -168,19 +170,19 @@ func TestInput(t *testing.T) { mediaDigest := delivery.ManifestDigest(media) keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} caps := []string{"container-runtime", "systemd", "wireguard"} - tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", - Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} + tunnel := &meshmoduleassigner.FoundTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", + Peers: []meshmoduleassigner.FoundTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} mk := func() *bus { return &bus{ - placements: map[string]delivery.Placement{ - "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", + placements: map[string]meshmoduleassigner.Placement{ + "anchor": {Node: "anchor", Decided: meshmoduleassigner.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}}, - Modules: []delivery.PlacedModule{{Module: overlay.Name}}, - Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, - "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, - Modules: []delivery.PlacedModule{{Module: overlay.Name}, - {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}, - {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, + Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name}}, + Holds: []meshmoduleassigner.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, + "laptop": {Node: "laptop", Decided: meshmoduleassigner.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name}, + {Module: "xterm", HasLayer: true, Ports: map[string]int64{"80": 8080}}, + {Module: "media", HasMeshWide: true, MeshWideValues: map[string]any{"name": "mesh"}, HasLayer: true, Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, }, records: map[string]nodeengine.NodeRecord{ @@ -291,7 +293,7 @@ func TestInput(t *testing.T) { running = append(running, c) } laptop := mk().placements["laptop"] - asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps) + asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*meshmoduleassigner.Placement{"laptop": &laptop}}, fps) if err == nil && len(held) > 0 { err = fmt.Errorf("modules held: %v", held) } @@ -303,7 +305,7 @@ func TestInput(t *testing.T) { } moved := mk().placements["anchor"] moved.Decided.Endpoint = "anchor.example:51821" - changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps) + changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*meshmoduleassigner.Placement{"laptop": &laptop, "anchor": &moved}}, fps) if err == nil && len(held) > 0 { err = fmt.Errorf("modules held: %v", held) } @@ -329,11 +331,11 @@ func TestInput(t *testing.T) { files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}}) one := func(placed ...string) *bus { - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} for _, m := range placed { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m}) } - return &bus{placements: map[string]delivery.Placement{"n1": p}, + return &bus{placements: map[string]meshmoduleassigner.Placement{"n1": p}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, @@ -384,9 +386,9 @@ func TestInput(t *testing.T) { b.placements["n1"] = p return b } - tunnelledAs := func() map[string]*delivery.Placement { + tunnelledAs := func() map[string]*meshmoduleassigner.Placement { p := tunnelled().placements["n1"] - return map[string]*delivery.Placement{"n1": &p} + return map[string]*meshmoduleassigner.Placement{"n1": &p} } folded := func() []compositions.Composed { b := tunnelled() @@ -434,13 +436,13 @@ func TestInput(t *testing.T) { if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) { t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name) } - placedAs := func(modules ...string) map[string]*delivery.Placement { + placedAs := func(modules ...string) map[string]*meshmoduleassigner.Placement { p := one(modules...).placements["n1"] - return map[string]*delivery.Placement{"n1": &p} + return map[string]*meshmoduleassigner.Placement{"n1": &p} } for name, c := range map[string]struct { b *bus - given map[string]*delivery.Placement + given map[string]*meshmoduleassigner.Placement running []compositions.Composed verdict string check func(nodeengine.CandidateAnswer) bool @@ -477,7 +479,7 @@ func TestInput(t *testing.T) { runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool { return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 }}, - "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused, + "cannot compose": {one("keeper"), map[string]*meshmoduleassigner.Placement{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool { return a.Refused != "" }}, // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want // names and the controller folds into the node's own, are folded there again: the node's own is the same, and @@ -497,16 +499,16 @@ func TestInput(t *testing.T) { t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) } } - if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { + if got, err := Placed(want, map[string]*meshmoduleassigner.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { t.Errorf("Placed with the node's own placement changes the input: %v", err) } - if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { + if got, err := Placed(want, map[string]*meshmoduleassigner.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err) } - if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil { + if _, err := Placed(want, map[string]*meshmoduleassigner.Placement{"stranger": {Node: "stranger"}}); err == nil { t.Error("Placed a node the input holds no node for") } - if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil { + if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*meshmoduleassigner.Placement{"laptop": {Node: "anchor"}}}); err == nil { t.Error("a given placement naming another node was taken") } @@ -517,8 +519,8 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) { - pj, _ := delivery.EncodePlacement(placed) + if gj, _ := meshmoduleassignerclient.EncodePlacement(got); !meshmoduleassignerclient.SamePlacement(got, placed) { + pj, _ := meshmoduleassignerclient.EncodePlacement(placed) t.Errorf("%s exported as %s, want %s", node, gj, pj) } } @@ -617,7 +619,7 @@ func TestInput(t *testing.T) { movedMesh := func(built bool) *bus { b := one() p := b.placements["n1"] - p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}} + p.Modules = []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}} p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator" b.placements["n1"] = p rec := b.records["n1"] @@ -766,7 +768,7 @@ func TestInput(t *testing.T) { withMailer := func(set bool) *bus { b := mk() p := b.placements["anchor"] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "mailer"}) b.placements["anchor"] = p b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)} b.manifests[delivery.ManifestDigest(mailer)] = mailer @@ -836,7 +838,7 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { } placements, _ := b.Placements(ctx) for _, p := range placements { - values, err := delivery.PlacementValues(p) + values, err := meshmoduleassignerclient.PlacementValues(p) for k, raw := range values { put(PlacementRead, k, raw, err) } diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go index 432e05a..beda4ff 100644 --- a/internal/compose/frombus/frombustest/frombustest.go +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -13,6 +13,7 @@ import ( "encoding/json" "errors" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "strings" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -25,7 +26,7 @@ var testdata embed.FS // Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set. type Bus struct { - Placement map[string]delivery.Placement + Placement map[string]meshmoduleassigner.Placement Record map[string]nodeengine.NodeRecord Module map[string]delivery.Module Manifests map[string][]byte @@ -39,7 +40,7 @@ type Bus struct { var _ frombus.Reader = (*Bus)(nil) -func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) { +func (b *Bus) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { return b.Placement, nil } func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { @@ -152,9 +153,9 @@ func Provisioned() (*Bus, error) { digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } fps := FPs() b := &Bus{ - Placement: map[string]delivery.Placement{Node: {Node: Node, - Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, - Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, + Placement: map[string]meshmoduleassigner.Placement{Node: {Node: Node, + Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, Module: map[string]delivery.Module{}, Manifests: map[string][]byte{}, diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index 8769825..a071e3b 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" engcomp "github.com/novox/mesh-host/internal/compositions" "reflect" "slices" @@ -67,7 +69,7 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { if row.bad != "" { if !row.elsewhere { p := b.Placement[Node] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "backup"}) b.Placement[Node] = p } digest := delivery.ManifestDigest([]byte(row.bad)) @@ -277,7 +279,7 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { } else { // taken out of the node's placement while what runs still names it: the account goes with it, never kept stale p := bb.Placement[Node] - p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m delivery.PlacedModule) bool { return m.Module == "mesh-provisioner-client" }) + p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m meshmoduleassigner.PlacedModule) bool { return m.Module == "mesh-provisioner-client" }) bb.Placement[Node] = p want = "" } @@ -311,7 +313,7 @@ func statesOf(t *testing.T, b *Bus) frombus.States { s[state][key] = raw } for node, p := range b.Placement { - raw, err := delivery.EncodePlacement(p) + raw, err := meshmoduleassignerclient.EncodePlacement(p) put(frombus.PlacementRead, "placement."+node, raw, err) } for node, rec := range b.Record { diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index 8a083b0..09ab4c8 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "testing" @@ -18,18 +19,18 @@ import ( // aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { t.Helper() - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} fps := map[string]string{} for i, raw := range manifests { module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: module}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: module}) b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))} b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw) fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64) } - b.placements = map[string]delivery.Placement{"n1": p} + b.placements = map[string]meshmoduleassigner.Placement{"n1": p} return b, fps } diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index 0c2a163..4c5dcab 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -14,6 +14,8 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "strings" "time" @@ -24,10 +26,12 @@ import ( // The states a module names in its manifest's `reads` to compose through StatesReader. const ( - PlacementRead = delivery.PlacementRead - ModulesRead = delivery.IndexerModule + ".modules" - ManifestsRead = delivery.IndexerModule + ".manifests" - BuildsRead = delivery.BuilderModule + ".builds" + // The holders' names are kept here, in the engine's own code, as the names of the states a manifest's reads use + // (ADR 0634 §1, finding F4); a seat's contract names a state locally, not the module that holds it. + PlacementRead = "mesh-delivery-assigner.placement" + ModulesRead = "mesh-delivery-indexer.modules" + ManifestsRead = "mesh-delivery-indexer.manifests" + BuildsRead = "mesh-delivery-builder.builds" HaveRead = "node-engine.have" ResolvedRead = "mesh-config.resolved" ) @@ -78,8 +82,8 @@ func (r statesReader) each(read string, visit func(key string, value []byte)) er return nil } -func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) { - keys := delivery.NewPlacementKeys() +func (r statesReader) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { + keys := meshmoduleassignerclient.NewKeys() err := r.each(PlacementRead, func(k string, v []byte) { _, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before }) diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 81fb035..984bbcd 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -10,6 +10,7 @@ package composer import ( "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "slices" "sort" "strings" @@ -131,7 +132,11 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { if err != nil { return CaseVerdict{}, err } - in, err := frombus.Placed(base, c.Placements) + placements, err := interimPlacements(c.Placements) + if err != nil { + return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) + } + in, err := frombus.Placed(base, placements) if err != nil { return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) } @@ -340,3 +345,20 @@ func SentInput(in compose.Input, node string) compose.Input { } return in } + +// interimPlacements carries the case's placements, typed as mesh-sdk's composer still types them, over to the seat's +// type through JSON. It goes when this package takes the case from the delivery SDK's composer, which holds them raw. +func interimPlacements(in map[string]*delivery.Placement) (map[string]*meshmoduleassigner.Placement, error) { + if in == nil { + return nil, nil + } + raw, err := json.Marshal(in) + if err != nil { + return nil, err + } + var out map[string]*meshmoduleassigner.Placement + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index 5ca69a1..c56f6b4 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "slices" @@ -451,9 +452,9 @@ func (c *Composer) composeShadow(ctx context.Context, running []engcomp.Composed } kept[module] = fp } - wants := map[string]delivery.Want{agent.NodeModule: {}} + wants := map[string]meshwants.WantsByWantNodeModuleValue{agent.NodeModule: {}} for module, fp := range fps { - wants[module] = delivery.Want{FP: fp} + wants[module] = meshwants.WantsByWantNodeModuleValue{Fp: fp} } c.mu.Lock() c.wants = wants diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index 6b1adcf..1d481a0 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -3,6 +3,8 @@ package selfcompose import ( "context" "encoding/json" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "github.com/novox/mesh-host/internal/buckets" "strings" "sync/atomic" @@ -82,8 +84,8 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { if a := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: accepted}); a.Verdict != nec.Unready || a.Refused == "" { t.Errorf("a shadow not ready answered %+v", a) } - place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) have, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) deadline := time.Now().Add(5 * time.Second) @@ -91,10 +93,10 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { for watching() < 6 && time.Now().Before(deadline) { time.Sleep(5 * time.Millisecond) } - b.put(t, delivery.PlacementBucket, "placement.n1", place) + b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) b.put(t, delivery.ModulesBucket, "lemurs", mod) - for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket} { + for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket} { b.send(bucket, agent.Update{Caught: true}) } for !s.ready.Load() && time.Now().Before(deadline) { @@ -180,8 +182,8 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { var tick atomic.Int64 env.Now = func() time.Time { return time.Unix(0, 0).Add(time.Duration(tick.Add(1)) * time.Second) } s := NewShadow(env, nil) - s.c.placements["n1"] = delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "lemurs"}}} + s.c.placements["n1"] = meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} s.c.records["n1"] = nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}} s.c.modules["lemurs"] = delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"} s.c.modules["other"] = delivery.Module{FP2: fp, Manifest: digestOther, Repository: "novox/x", Commit: "c"} diff --git a/internal/selfcompose/composedstate.go b/internal/selfcompose/composedstate.go index 5288b12..af2c1f4 100644 --- a/internal/selfcompose/composedstate.go +++ b/internal/selfcompose/composedstate.go @@ -257,7 +257,7 @@ func (c *Composer) pastKeeping() []string { c.mu.Lock() wanted := map[string]string{} for module, w := range c.wants { - wanted[module] = w.FP + wanted[module] = w.Fp } c.mu.Unlock() type kept struct { diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index 86ac8a7..d971e0e 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -4,7 +4,11 @@ import ( "context" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets" + "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "strings" @@ -86,20 +90,20 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { if err != nil { t.Fatal(err) } - place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"}) - want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) - nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) - b.put(t, delivery.PlacementBucket, "placement.n1", place) + want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) + nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) + b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "oldie", omod) - b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) - b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + b.put(t, buckets.Wants, "want.n1.lemurs", want) + b.put(t, buckets.Wants, "want.n1.node", nodeWant) + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } for u := next(t, out); !u.Caught; u = next(t, out) { @@ -170,7 +174,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { if past := keep.pastKeeping(); !slices.Equal(past, []string{keys[0]}) { t.Errorf("past keeping: %v, want the oldest of six, %s", past, keys[0]) } - keep.wants = map[string]delivery.Want{"lemurs": {FP: delivery.SourcePrefix2 + strings.Repeat("1", 64)}} + keep.wants = map[string]meshwants.WantsByWantNodeModuleValue{"lemurs": {Fp: delivery.SourcePrefix2 + strings.Repeat("1", 64)}} if past := keep.pastKeeping(); len(past) != 0 { t.Errorf("past keeping with a want naming the oldest: %v, want none", past) } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 81dff9c..3f39ef9 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -21,6 +21,9 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -129,11 +132,11 @@ type Composer struct { env Env mu sync.Mutex - wants map[string]delivery.Want // by module, the node's own under agent.NodeModule - placements map[string]delivery.Placement + wants map[string]meshwants.WantsByWantNodeModuleValue // by module, the node's own under agent.NodeModule + placements map[string]meshmoduleassigner.Placement // placementKeys is the seat's state placement key by key in either form, each node's placement assembled from it - // (delivery.PlacementKeys; research 072 row 37): a write of the holder's under way is never composed half. - placementKeys *delivery.PlacementKeys + // (meshmoduleassignerclient.Keys; research 072 row 37): a write of the holder's under way is never composed half. + placementKeys *meshmoduleassignerclient.Keys records map[string]nodeengine.NodeRecord // inOwn is, by module, whether this node's have of it names no fingerprint of the new form (mesh-wireguard, a module // at a build of the old form): the node runs it inside its own composition, and composes it there again (issue 1126). @@ -300,7 +303,7 @@ func New(env Env) *Composer { if env.Say == nil { env.Say = func(string) {} } - return &Composer{env: env, wants: map[string]delivery.Want{}, placements: map[string]delivery.Placement{}, placementKeys: delivery.NewPlacementKeys(), + return &Composer{env: env, wants: map[string]meshwants.WantsByWantNodeModuleValue{}, placements: map[string]meshmoduleassigner.Placement{}, placementKeys: meshmoduleassignerclient.NewKeys(), records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{}, manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]delivery.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} @@ -355,8 +358,8 @@ type input struct { func (c *Composer) inputs() []input { node := c.env.Node return []input{ - {delivery.AssignerWantsBucket, "want." + node + ".>", (*Composer).takeWant, nil}, - {delivery.PlacementBucket, ">", (*Composer).takePlacement, nil}, + {buckets.Wants, "want." + node + ".>", (*Composer).takeWant, nil}, + {buckets.Placement, ">", (*Composer).takePlacement, nil}, // Every node's record, and this node's own haves (what it runs inside its own composition): two watches, each // within the node's grant (buscompose: readGrants of the have bucket at `*.node`, NodeKeysGrants at `.>`). // A watch of every key is refused by the bus (issue 1126, 2026-10-27 09:21: the fold's `>` stopped every agent). @@ -395,7 +398,7 @@ func (c *Composer) takeBuildDone(u agent.Update) bool { c.mu.Unlock() // A module placed here is composed at its trunk's build once that is done, for the composition state, before its // want moves (ADR 0593 §6). - if !(wanted && w.FP == fp) && !((wanted || placedHere) && m.FP2 == fp) { + if !(wanted && w.Fp == fp) && !((wanted || placedHere) && m.FP2 == fp) { return false } b, err := delivery.DecodeBuilderBuild(u.Value) @@ -604,22 +607,22 @@ func (c *Composer) entry(bucket, key string) (v any, has, known bool) { c.mu.Lock() defer c.mu.Unlock() switch bucket { - case delivery.AssignerWantsBucket: + case buckets.Wants: // A want is composed by its fingerprint alone (fingerprints); its words, rule and newest change nothing composed. module := agent.NodeModule - if _, err := delivery.ParseWantNodeKey(key); err != nil { - node, m, err := delivery.ParseWantKey(key) - if err != nil || node != c.env.Node { + if _, ok := meshwants.ParseWantsByWantNodeNodeKey(key); !ok { + node, m, ok := meshwants.ParseWantsByWantNodeModuleKey(key) + if !ok || node != c.env.Node { return nil, false, true } module = m } w, ok := c.wants[module] - return w.FP, ok, true - case delivery.PlacementBucket: + return w.Fp, ok, true + case buckets.Placement: // Any key of a node, its node key, a module key or its legacy key, stands for the node's whole placement: a // module key written before its node key names it changes nothing composed. - node, _, _, err := delivery.ParsePlacementKey(key) + node, _, _, err := meshmoduleassignerclient.ParseKey(key) if err != nil { return nil, false, true } @@ -650,20 +653,20 @@ func (c *Composer) entry(bucket, key string) (v any, has, known bool) { func (c *Composer) takeWant(u agent.Update) { c.mu.Lock() defer c.mu.Unlock() - if _, err := delivery.ParseWantNodeKey(u.Key); err == nil { + if _, ok := meshwants.ParseWantsByWantNodeNodeKey(u.Key); ok { if u.Deleted { delete(c.wants, agent.NodeModule) } else { - c.wants[agent.NodeModule] = delivery.Want{} + c.wants[agent.NodeModule] = meshwants.WantsByWantNodeModuleValue{} } return } - node, module, err := delivery.ParseWantKey(u.Key) - if err != nil || node != c.env.Node { + node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(u.Key) + if !ok || node != c.env.Node { return } - w, err := delivery.DecodeWant(u.Value) - if u.Deleted || err != nil || w.Removed || w.FP == "" { + w, err := meshwants.DecodeWantsByWantNodeModuleValue(u.Value) + if u.Deleted || err != nil || w.Removed || w.Fp == "" { delete(c.wants, module) return } @@ -676,7 +679,7 @@ func (c *Composer) takePlacement(u agent.Update) { } c.mu.Lock() if c.placementKeys == nil { - c.placementKeys = delivery.NewPlacementKeys() + c.placementKeys = meshmoduleassignerclient.NewKeys() } node, err := c.placementKeys.Put(u.Key, u.Value, u.Revision, u.Deleted || len(u.Value) == 0) if node != "" { @@ -769,7 +772,7 @@ func (c *Composer) Machines() (byAddress map[string]string, hub string) { return byAddress, hub } -func (c *Composer) inOwnOf(p delivery.Placement) []string { +func (c *Composer) inOwnOf(p meshmoduleassigner.Placement) []string { var out []string for _, m := range p.Modules { if c.inOwn[m.Module] { @@ -859,8 +862,8 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { if module == agent.NodeModule { continue } - fps[module] = w.FP - if m, ok := c.modules[module]; ok && m.FP2 != w.FP { + fps[module] = w.Fp + if m, ok := c.modules[module]; ok && m.FP2 != w.Fp { behind = append(behind, module) } } @@ -1310,7 +1313,7 @@ var _ frombus.SignersReader = reader{} // certificateWanted says whether a module the placement p puts on this node asks for a certificate for the machine's // internal name (its manifest's `certificate`), read from the indexer's manifests; a manifest that cannot be read is // taken as asking none, and the composition says what it misses. -func (c *Composer) certificateWanted(ctx context.Context, r reader, p delivery.Placement) bool { +func (c *Composer) certificateWanted(ctx context.Context, r reader, p meshmoduleassigner.Placement) bool { for _, m := range p.Modules { c.mu.Lock() digest := c.modules[m.Module].Manifest @@ -1338,7 +1341,7 @@ func (c *Composer) certificateWanted(ctx context.Context, r reader, p delivery.P // certifyIfWanted asks Certify once per serving key when a module placed here asks for a certificate and this node's // record holds none for its internal name and serving key from an authority it trusts (the one it holds, or the vault's). -func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p delivery.Placement) { +func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p meshmoduleassigner.Placement) { if c.env.Certify == nil || !c.certificateWanted(ctx, r, p) { return } @@ -1367,10 +1370,10 @@ func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p delivery.Pla c.mu.Unlock() } -func (r reader) Placements(context.Context) (map[string]delivery.Placement, error) { +func (r reader) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { r.c.mu.Lock() defer r.c.mu.Unlock() - out := make(map[string]delivery.Placement, len(r.c.placements)) + out := make(map[string]meshmoduleassigner.Placement, len(r.c.placements)) for k, v := range r.c.placements { out[k] = v } @@ -1719,9 +1722,9 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string if m == agent.NodeModule { continue } - fps[m] = w.FP - if mm, ok := c.modules[m]; ok && mm.FP2 != w.FP { - kept[m] = w.FP + fps[m] = w.Fp + if mm, ok := c.modules[m]; ok && mm.FP2 != w.Fp { + kept[m] = w.Fp } } fps[module] = fp diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 9cada98..d029b67 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -10,7 +10,11 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets" + "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -189,21 +193,21 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { okey, _ := delivery.ManifestsKey(odigest) b.kv[delivery.ManifestsBucket+"/"+okey] = oman omod, _ := delivery.EncodeModule(delivery.Module{FP2: delivery.SourcePrefix2 + strings.Repeat("c", 64), Manifest: odigest, Repository: "novox/o", Commit: "c"}) - place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) - want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) - nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) - b.put(t, delivery.PlacementBucket, "placement.n1", place) + want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) + nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) + b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) oldHave, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) b.put(t, buckets.Have, "n1.oldie", oldHave) b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "oldie", omod) - b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) - b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + b.put(t, buckets.Wants, "want.n1.lemurs", want) + b.put(t, buckets.Wants, "want.n1.node", nodeWant) + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } got := map[string]bool{} @@ -431,22 +435,22 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.mu.Unlock() mfp := delivery.SourcePrefix2 + strings.Repeat(map[string]string{"de-spiegel": "d", "proxy": "e"}[m], 64) c3.modules[m] = delivery.Module{FP2: mfp, Manifest: dig, Repository: "novox/" + m, Commit: "c"} - c3.wants[m] = delivery.Want{FP: mfp, Self: true} + c3.wants[m] = meshwants.WantsByWantNodeModuleValue{Fp: mfp, Self: true} } p3 := c3.placements["n1"] - p3.Modules = append(append([]delivery.PlacedModule{}, p3.Modules...), delivery.PlacedModule{Module: "proxy"}, delivery.PlacedModule{Module: "de-spiegel"}) + p3.Modules = append(append([]meshmoduleassigner.PlacedModule{}, p3.Modules...), meshmoduleassigner.PlacedModule{Module: "proxy"}, meshmoduleassigner.PlacedModule{Module: "de-spiegel"}) c3.placements["n1"] = p3 r3 := c3.records["n1"] r3.Capabilities = []string{"systemd", "container-runtime"} c3.records["n1"] = r3 - spiegelKey, _ := compositions.Key("n1", "de-spiegel", c3.wants["de-spiegel"].FP) - proxyKey, _ := compositions.Key("n1", "proxy", c3.wants["proxy"].FP) + spiegelKey, _ := compositions.Key("n1", "de-spiegel", c3.wants["de-spiegel"].Fp) + proxyKey, _ := compositions.Key("n1", "proxy", c3.wants["proxy"].Fp) c3.handed[spiegelKey] = "sha256:" + strings.Repeat("5", 64) // what the node runs of it, handed before handed3 := map[string]agent.Update{} for _, u := range c3.compose(ctx) { handed3[u.Key] = u } - lemursKey3, _ := compositions.Key("n1", "lemurs", c3.wants["lemurs"].FP) + lemursKey3, _ := compositions.Key("n1", "lemurs", c3.wants["lemurs"].Fp) if u, ok := handed3[lemursKey3]; !ok || u.Deleted { t.Errorf("lemurs, which composes, is not handed beside de-spiegel's refusal: %v (told %q)", handed3, *told) } @@ -486,9 +490,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { c4.modules["power"] = delivery.Module{FP2: powerFP, Manifest: powerDig, Repository: "novox/mesh-catalog", Commit: "ca8d6f33", // The indexer's verdict on the captured manifest (ADR 0578): the engine judges nothing, it takes this. Verdict: delivery.VerdictRefused, Problems: []string{`bar item "shows" is none of them`}} - c4.wants["power"] = delivery.Want{FP: powerFP, Self: true} + c4.wants["power"] = meshwants.WantsByWantNodeModuleValue{Fp: powerFP, Self: true} p4 := c4.placements["n1"] - p4.Modules = append(append([]delivery.PlacedModule{}, p4.Modules...), delivery.PlacedModule{Module: "power"}) + p4.Modules = append(append([]meshmoduleassigner.PlacedModule{}, p4.Modules...), meshmoduleassigner.PlacedModule{Module: "power"}) c4.placements["n1"] = p4 handed4 := map[string]agent.Update{} for _, u := range c4.compose(ctx) { @@ -506,7 +510,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // still carries its resources, so retiring the read removes nothing of it. c5, told5 := fresh(env.Get) p5 := c5.placements["n1"] - p5.Modules = append(append([]delivery.PlacedModule{}, p5.Modules...), delivery.PlacedModule{Module: overlay.Name}) + p5.Modules = append(append([]meshmoduleassigner.PlacedModule{}, p5.Modules...), meshmoduleassigner.PlacedModule{Module: overlay.Name}) p5.Decided.Endpoint = "n1.example:51820" c5.placements["n1"] = p5 c5.inOwn[overlay.Name] = true @@ -587,14 +591,14 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // Issue 774, 2026-10-31: every put under want.n1.> made a pass, and each pass asked the vault for the node's own // secrets. A key written again with what it held, or a want whose words moved and not its build, makes none. - worded, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true, Words: "lemurs is held at aaaaaaaaaaaa"}) - elsewhere, _ := delivery.EncodeWant(delivery.Want{FP: delivery.SourcePrefix2 + strings.Repeat("e", 64), Self: true}) + worded, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true, Words: "lemurs is held at aaaaaaaaaaaa"}) + elsewhere, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("e", 64), Self: true}) // Research 072 row 37: the placement converted to one key per module and node moves nothing composed; a write in // keys makes its pass at its node key, the commit, and not at a module key written before it nor at the mirror. - placeBase := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}} - placeKeys := delivery.NewPlacementKeys() - keyRows := func(after delivery.Placement, what string) []struct { + placeBase := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}} + placeKeys := meshmoduleassignerclient.NewKeys() + keyRows := func(after meshmoduleassigner.Placement, what string) []struct { name, bucket, key string value []byte pass bool @@ -614,13 +618,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { name, bucket, key string value []byte pass bool - }{what + ": " + w.Key, delivery.PlacementBucket, w.Key, w.Value, w.Commit && what != "converted"}) + }{what + ": " + w.Key, buckets.Placement, w.Key, w.Value, w.Commit && what != "converted"}) } return rows } _, _ = placeKeys.Put("placement.n1", place, 1, false) lemursMoved := placeBase - lemursMoved.Modules = []delivery.PlacedModule{{Module: "lemurs", Values: map[string]any{"greeting": "hoi"}}, {Module: "oldie"}} + lemursMoved.Modules = []meshmoduleassigner.PlacedModule{{Module: "lemurs", Values: map[string]any{"greeting": "hoi"}}, {Module: "oldie"}} var placementRows []struct { name, bucket, key string value []byte @@ -628,7 +632,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } for _, step := range []struct { what string - p delivery.Placement + p meshmoduleassigner.Placement }{{"converted", placeBase}, {"lemurs moved", lemursMoved}, {"lemurs back", placeBase}} { placementRows = append(placementRows, keyRows(step.p, step.what)...) } @@ -637,13 +641,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { value []byte pass bool }{ - {"lemurs's want again", delivery.AssignerWantsBucket, "want.n1.lemurs", want, false}, - {"the node's own want again", delivery.AssignerWantsBucket, "want.n1.node", nodeWant, false}, - {"lemurs's want with other words", delivery.AssignerWantsBucket, "want.n1.lemurs", worded, false}, - {"the placement again", delivery.PlacementBucket, "placement.n1", place, false}, + {"lemurs's want again", buckets.Wants, "want.n1.lemurs", want, false}, + {"the node's own want again", buckets.Wants, "want.n1.node", nodeWant, false}, + {"lemurs's want with other words", buckets.Wants, "want.n1.lemurs", worded, false}, + {"the placement again", buckets.Placement, "placement.n1", place, false}, {"lemurs's module again", delivery.ModulesBucket, "lemurs", mod, false}, - {"lemurs's want at another build", delivery.AssignerWantsBucket, "want.n1.lemurs", elsewhere, true}, - {"lemurs's want back at its build", delivery.AssignerWantsBucket, "want.n1.lemurs", want, true}, + {"lemurs's want at another build", buckets.Wants, "want.n1.lemurs", elsewhere, true}, + {"lemurs's want back at its build", buckets.Wants, "want.n1.lemurs", want, true}, }, placementRows...) { before := passes() b.put(t, row.bucket, row.key, row.value) @@ -690,18 +694,18 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - tplace, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "tool"}}}) + tplace, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "tool"}}}) tmod, _ := delivery.EncodeModule(delivery.Module{FP2: next2, Manifest: tdigest, Repository: "novox/t", Commit: "c"}) - twant, _ := delivery.EncodeWant(delivery.Want{FP: next2, Self: true}) + twant, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: next2, Self: true}) building, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: "building"}) bkey, _ := delivery.BuilderBuildKey("tool", next2) tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = building - tb.put(t, delivery.PlacementBucket, "placement.n1", tplace) + tb.put(t, buckets.Placement, "placement.n1", tplace) tb.put(t, buckets.Have, "n1.node", have) tb.put(t, delivery.ModulesBucket, "tool", tmod) - tb.put(t, delivery.AssignerWantsBucket, "want.n1.tool", twant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, + tb.put(t, buckets.Wants, "want.n1.tool", twant) + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { tb.send(bucket, agent.Update{Caught: true}) } @@ -729,18 +733,18 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // A want removed takes its composition back. - removed, _ := delivery.EncodeWant(delivery.Want{Removed: true}) - b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", removed) + removed, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true}) + b.put(t, buckets.Wants, "want.n1.lemurs", removed) if u := next(t, out); u.Key != lemursKey || !u.Deleted { t.Fatalf("a removed want handed %+v", u) } // A want of a module whose manifest cannot be read: said once, nothing handed, what was handed stands. gone := delivery.SourcePrefix2 + strings.Repeat("b", 64) - missing, _ := delivery.EncodeWant(delivery.Want{FP: gone, Self: true}) - b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", missing) + missing, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: gone, Self: true}) + b.put(t, buckets.Wants, "want.n1.lemurs", missing) quiet(t, out) - b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", missing) + b.put(t, buckets.Wants, "want.n1.lemurs", missing) quiet(t, out) b.mu.Lock() n := 0 @@ -756,7 +760,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // An own secret comes sealed from the vault into the composition; sealed again at the same generation it is the value // first had, so nothing is handed again; a new generation is handed. - b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) + b.put(t, buckets.Wants, "want.n1.lemurs", want) if u := next(t, out); u.Key != lemursKey { t.Fatalf("lemurs wanted back handed %s", u.Key) } @@ -769,21 +773,21 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.kv[delivery.ManifestsBucket+"/"+kkey] = kman b.mu.Unlock() kmod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}) - both, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}, {Module: "keeper"}}}) + both, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}, {Module: "keeper"}}}) b.put(t, delivery.ModulesBucket, "keeper", kmod) // n1 is held in keys per module and node since the rows above, which a legacy key no longer moves. - bothPlaced, _ := delivery.DecodePlacement(both) + bothPlaced, _ := meshmoduleassignerclient.DecodePlacement(both) bothWrites, err := placeKeys.Writes("n1", &bothPlaced) if err != nil { t.Fatal(err) } for _, w := range bothWrites { if w.Value != nil { - b.put(t, delivery.PlacementBucket, w.Key, w.Value) + b.put(t, buckets.Placement, w.Key, w.Value) } } - b.put(t, delivery.AssignerWantsBucket, "want.n1.keeper", want) + b.put(t, buckets.Wants, "want.n1.keeper", want) keeperKey, _ := compositions.Key("n1", "keeper", fp) var handed string for handed == "" { @@ -1031,12 +1035,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // compose; a group whose keys went is no longer answered. What runs is never composed from a candidate: the // Composer does not read the state. for _, in := range New(env).inputs() { - if in.bucket == delivery.PlacementCandidatesBucket { + if in.bucket == buckets.PlacementCandidates { t.Fatal("the node's own composer reads placement candidates") } } sb := newPatternBus() - trunk := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}}} + trunk := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} mods := map[string]delivery.Module{"lemurs": {FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}, "keeper": {FP2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}} readOnly := &fakeBus{kv: map[string][]byte{delivery.ManifestsBucket + "/" + key: man, delivery.ManifestsBucket + "/" + kkey: kman}} @@ -1050,7 +1054,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { return json.RawMessage(`{"node":"n1","key":"sk","own":[]}`), nil // nothing issued for keeper: it is not placed here } // What n1 runs: lemurs and its own composition, as its composer composes them from the trunk. - running, _, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]delivery.Placement{"n1": trunk}, + running, _, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]meshmoduleassigner.Placement{"n1": trunk}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: mods, manifests: map[string][]byte{digest: []byte(lemurs), kdigest: []byte(keeper)}}, frombus.Mesh{}, frombus.Scope{Node: "n1"}, map[string]string{"lemurs": fp}) if err != nil { @@ -1081,47 +1085,47 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { defer scancel() // Review of L2b, round 1, finding 2: the candidates' state is not there when the node starts (a delivery before the // indexer made it): the shadow tries again, and answers once it appears. - sb.missing(delivery.PlacementCandidatesBucket, true) + sb.missing(buckets.PlacementCandidates, true) kept := make(chan struct{}) go func() { sh.Keep(sctx, func(int) time.Duration { return 5 * time.Millisecond }); close(kept) }() time.Sleep(30 * time.Millisecond) - sb.missing(delivery.PlacementCandidatesBucket, false) + sb.missing(buckets.PlacementCandidates, false) sb.made(6) rec, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) - tp, _ := delivery.EncodePlacement(trunk) - sb.put(delivery.PlacementBucket, "placement.n1", tp, 1) + tp, _ := meshmoduleassignerclient.EncodePlacement(trunk) + sb.put(buckets.Placement, "placement.n1", tp, 1) sb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { raw, _ := delivery.EncodeModule(m) sb.put(delivery.ModulesBucket, name, raw, 1) } - wantLemurs, _ := delivery.WantKey("n1", "lemurs") + wantLemurs := meshwants.WantsByWantNodeModuleKey("n1", "lemurs") var lemursDigest string for _, c := range running { if c.Module == "lemurs" { lemursDigest, _ = compositions.DigestOf(c) } } - wantRaw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: lemursDigest}) + wantRaw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Composed: lemursDigest}) if err != nil { t.Fatal(err) } - sb.put(delivery.AssignerWantsBucket, wantLemurs, wantRaw, 1) - wantNode, _ := delivery.WantNodeKey("n1") - nodeRaw, _ := delivery.EncodeWant(delivery.Want{}) - sb.put(delivery.AssignerWantsBucket, wantNode, nodeRaw, 1) + sb.put(buckets.Wants, wantLemurs, wantRaw, 1) + wantNode := meshwants.WantsByWantNodeNodeKey("n1") + nodeRaw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{}) + sb.put(buckets.Wants, wantNode, nodeRaw, 1) candidate := func(group string, rev uint64, modules ...string) { p := trunk p.Modules = nil for _, m := range modules { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m}) } - raw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: group, Placement: &p}) + raw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: group, Placement: &p}) if err != nil { t.Fatal(err) } - k, _ := delivery.PlacementCandidateKey(group, "n1") - sb.put(delivery.PlacementCandidatesBucket, k, raw, rev) + k := meshmoduleassigner.CandidatesByProposalNodeKey(group, "n1") + sb.put(buckets.PlacementCandidates, k, raw, rev) } candidate("same", 3, "lemurs") candidate("adds", 4, "lemurs", "keeper") @@ -1129,20 +1133,20 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { candidate("ghost", 6, "lemurs", "ghost") // A head deleting n1's placement file (the seed's revert, mesh-catalog #526): n1 returns to the controller's // compositions, answered as such, not as every module removed. - if raw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "deletes"}); err != nil { + if raw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "deletes"}); err != nil { t.Fatal(err) } else { - k, _ := delivery.PlacementCandidateKey("deletes", "n1") - sb.put(delivery.PlacementCandidatesBucket, k, raw, 7) + k := meshmoduleassigner.CandidatesByProposalNodeKey("deletes", "n1") + sb.put(buckets.PlacementCandidates, k, raw, 7) } // A proposal that changes another node's key and reaches n1's composition (ADR 0567 §3): n1's candidate holds no // placement of its own change (Reached, nil), and n1 answers against its current placement, never as one whose key // the proposal takes away. - if raw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "reach", Reached: true}); err != nil { + if raw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "reach", Reached: true}); err != nil { t.Fatal(err) } else { - k, _ := delivery.PlacementCandidateKey("reach", "n1") - sb.put(delivery.PlacementCandidatesBucket, k, raw, 8) + k := meshmoduleassigner.CandidatesByProposalNodeKey("reach", "n1") + sb.put(buckets.PlacementCandidates, k, raw, 8) } sb.caught() var answers map[string]nodeengine.CandidateAnswer @@ -1247,7 +1251,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { settled := answering() lemursHave("its tool lemurs_health: no answer within 10s", 11) lemursHave("its tool lemurs_health: no answer within 20s", 12) - sb.put(delivery.PlacementBucket, "placement.n1", tp, 2) + sb.put(buckets.Placement, "placement.n1", tp, 2) lraw, _ := delivery.EncodeModule(mods["lemurs"]) sb.put(delivery.ModulesBucket, "lemurs", lraw, 2) drain() @@ -1300,18 +1304,18 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if got := lastSaid("answering placement candidate adds "); got != "answering placement candidate adds (revision 9; woken by a retry after a read the bus did not answer)" { t.Errorf("an answer composed again after the bus did not answer said %q", got) } - k, _ := delivery.PlacementCandidateKey("same", "n1") - sb.del(delivery.PlacementCandidatesBucket, k) + k := meshmoduleassigner.CandidatesByProposalNodeKey("same", "n1") + sb.del(buckets.PlacementCandidates, k) waitFor("the deleted group's answer stays", func(a map[string]nodeengine.CandidateAnswer) bool { _, ok := a["same"]; return !ok && len(a) == 5 }) // Review of L2b, round 2: a watch ends while the link stays; a group's key is deleted before the shadow watches // again. The new run reads afresh, so that group is no longer answered. - sb.missing(delivery.PlacementCandidatesBucket, true) + sb.missing(buckets.PlacementCandidates, true) sb.end(delivery.ModulesBucket) - dropsKey, _ := delivery.PlacementCandidateKey("drops", "n1") - sb.del(delivery.PlacementCandidatesBucket, dropsKey) + dropsKey := meshmoduleassigner.CandidatesByProposalNodeKey("drops", "n1") + sb.del(buckets.PlacementCandidates, dropsKey) time.Sleep(30 * time.Millisecond) - sb.missing(delivery.PlacementCandidatesBucket, false) + sb.missing(buckets.PlacementCandidates, false) waitFor("a group deleted while the shadow was down is still answered", func(a map[string]nodeengine.CandidateAnswer) bool { _, ok := a["drops"] return !ok && len(a) == 4 @@ -1324,13 +1328,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { env := refused.env() watch := env.Watch env.Watch = func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) { - if bucket == delivery.PlacementBucket { + if bucket == buckets.Placement { return nil, fmt.Errorf("nats: Permissions Violation for Subscription to %s", bucket) } return watch(ctx, bucket, pattern) } out, err := New(env).Watch(ctx, "") - if out != nil || err == nil || !strings.Contains(err.Error(), delivery.PlacementBucket) || !strings.Contains(err.Error(), "cannot be watched") { + if out != nil || err == nil || !strings.Contains(err.Error(), buckets.Placement) || !strings.Contains(err.Error(), "cannot be watched") { t.Errorf("an unreadable placement: composed %v, said %v", out != nil, err) } } @@ -1351,20 +1355,20 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { defer ncancel() go func() { _ = nsh.Run(nctx) }() nb.made(6) - nb.put(delivery.PlacementBucket, "placement.n1", tp, 1) + nb.put(buckets.Placement, "placement.n1", tp, 1) nb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { raw, _ := delivery.EncodeModule(m) nb.put(delivery.ModulesBucket, name, raw, 1) } - sameRaw, _ := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "same", Placement: &trunk}) - sameKey, _ := delivery.PlacementCandidateKey("same", "n1") - nb.put(delivery.PlacementCandidatesBucket, sameKey, sameRaw, 3) + sameRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "same", Placement: &trunk}) + sameKey := meshmoduleassigner.CandidatesByProposalNodeKey("same", "n1") + nb.put(buckets.PlacementCandidates, sameKey, sameRaw, 3) // Issue 1126, T3: an older group (a closed pull request's candidate, rev 2) is answered after the newest. - oldRaw, _ := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "older", Placement: &trunk}) - oldKey, _ := delivery.PlacementCandidateKey("older", "n1") - nb.put(delivery.PlacementCandidatesBucket, oldKey, oldRaw, 2) - for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, delivery.BuilderBuildsBucket} { + oldRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "older", Placement: &trunk}) + oldKey := meshmoduleassigner.CandidatesByProposalNodeKey("older", "n1") + nb.put(buckets.PlacementCandidates, oldKey, oldRaw, 2) + for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, delivery.ModulesBucket, delivery.BuilderBuildsBucket} { nb.send(bucket, agent.Update{Caught: true}) } select { @@ -1446,7 +1450,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // trunk's. inOwnRunning := []compositions.Composed{{Node: "n1", Module: "lemurs", FP: fp}} other := "src2:" + strings.Repeat("9", 64) - gotFps := shadowBuilds(inOwnRunning, delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "nats"}, {Module: "keeper"}}}, + gotFps := shadowBuilds(inOwnRunning, meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "nats"}, {Module: "keeper"}}}, map[string]delivery.Module{"lemurs": {FP2: other}, "nats": {FP2: other}, "keeper": {FP2: other}}, []string{"nats"}) if fmt.Sprint(gotFps) != fmt.Sprint(map[string]string{"lemurs": fp, "keeper": other}) { t.Errorf("shadow builds %v; want lemurs at what runs, keeper at the trunk's, nats at none (folded)", gotFps) @@ -1511,12 +1515,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { cc.manifests[cdigest] = []byte(withCert) cc.modules = map[string]delivery.Module{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}} cc.records["n1"] = nodeengine.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))} - onlyLemurs := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}}} + onlyLemurs := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs) if certAsks != 0 { t.Errorf("no module asks for a certificate, and the vault was asked %d times", certAsks) } - withWeb := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "web"}}} + withWeb := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "web"}}} cc.certifyIfWanted(context.Background(), reader{c: cc}, withWeb) cc.certifyIfWanted(context.Background(), reader{c: cc}, withWeb) if certAsks != 1 { @@ -1657,13 +1661,13 @@ func (b *patternBus) caught() { // fixedReader is frombus's Reader over values held, for composing what a node runs. type fixedReader struct { - placements map[string]delivery.Placement + placements map[string]meshmoduleassigner.Placement records map[string]nodeengine.NodeRecord modules map[string]delivery.Module manifests map[string][]byte } -func (r fixedReader) Placements(context.Context) (map[string]delivery.Placement, error) { +func (r fixedReader) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { return r.placements, nil } func (r fixedReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { @@ -1783,18 +1787,18 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { if err != nil { t.Fatal(err) } - place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) - want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) - nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) - b.put(t, delivery.PlacementBucket, "placement.n1", place) + want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) + nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) + b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) b.put(t, delivery.ModulesBucket, "lemurs", mod) - b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) - b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) - for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + b.put(t, buckets.Wants, "want.n1.lemurs", want) + b.put(t, buckets.Wants, "want.n1.node", nodeWant) + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } handed := map[string]bool{} diff --git a/internal/selfcompose/shadow.go b/internal/selfcompose/shadow.go index c2d03b1..4ca4646 100644 --- a/internal/selfcompose/shadow.go +++ b/internal/selfcompose/shadow.go @@ -7,6 +7,8 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "sort" @@ -99,7 +101,7 @@ func wokenBy(had answeredAt, done, retry bool, at answeredAt) string { // candidateKey is one candidate key as last read: its value and revision. type candidateKey struct { - c delivery.PlacementCandidate + c meshmoduleassigner.CandidatesByProposalNodeValue rev uint64 } @@ -160,8 +162,8 @@ func (s *Shadow) Run(ctx context.Context) error { node := c.env.Node // In inputWords' order. ins := []input{ - {delivery.PlacementCandidatesBucket, ">", nil, nil}, - {delivery.PlacementBucket, ">", (*Composer).takePlacement, nil}, + {buckets.PlacementCandidates, ">", nil, nil}, + {buckets.Placement, ">", (*Composer).takePlacement, nil}, {buckets.Have, "*." + nodeengineclient.NodeWord, nil, nil}, {delivery.ModulesBucket, ">", (*Composer).takeModule, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, @@ -282,8 +284,8 @@ func revsOf(keys map[string]candidateKey) string { // takeCandidate takes one candidate key, and says whether it changed anything. func (s *Shadow) takeCandidate(u agent.Update) bool { - proposal, node, err := delivery.ParsePlacementCandidateKey(u.Key) - if err != nil { + proposal, node, ok := meshmoduleassigner.ParseCandidatesByProposalNodeKey(u.Key) + if !ok { return false } s.mu.Lock() @@ -298,7 +300,7 @@ func (s *Shadow) takeCandidate(u agent.Update) bool { } return true } - c, err := delivery.DecodePlacementCandidate(u.Value) + c, err := meshmoduleassigner.DecodeCandidatesByProposalNodeValue(u.Value) if err != nil || c.Proposal != proposal || (c.Placement != nil && c.Placement.Node != node) { // Not one: read as gone. if _, ok := s.candidates[proposal][node]; ok { @@ -506,7 +508,7 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengin defer c.waiting("composing")() s.mu.Lock() keys := s.candidates[proposal] - given := make(map[string]*delivery.Placement, len(keys)) + given := make(map[string]*meshmoduleassigner.Placement, len(keys)) for n, k := range keys { if k.c.Reached && k.c.Placement == nil { continue // reached, with no key of its own: nothing laid over it @@ -562,9 +564,9 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengin } // The boxes it runs, by what each module's have and its own key say after a restart (Held), are reused for the // modules it runs: a value sealed again is no difference. - held := map[string]delivery.Want{agent.NodeModule: {}} + held := map[string]meshwants.WantsByWantNodeModuleValue{agent.NodeModule: {}} for module := range fps { - held[module] = delivery.Want{FP: fps[module]} + held[module] = meshwants.WantsByWantNodeModuleValue{Fp: fps[module]} } c.mu.Lock() c.wants = held @@ -663,7 +665,7 @@ func asHavesSay(a nodeengine.CandidateAnswer, ran map[string]string) nodeengine. // of the old form) is given none, so it is folded there again, as the node's own composer folds it, rather than // composed at the trunk's build as if the candidate added it (issue 1126: nats and photos have a trunk build that no // want names). -func shadowBuilds(running []engcomp.Composed, placement delivery.Placement, modules map[string]delivery.Module, inOwn []string) map[string]string { +func shadowBuilds(running []engcomp.Composed, placement meshmoduleassigner.Placement, modules map[string]delivery.Module, inOwn []string) map[string]string { fps := map[string]string{} for _, r := range running { if r.Module != "" && r.FP != "" { -- 2.54.0 From 669589b76baa8d7fe1c5f1cda733a9aac0d2f23b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:36:05 +0100 Subject: [PATCH 41/73] =?UTF-8?q?Node-engine:=20the=20placement's=20bucket?= =?UTF-8?q?=20and=20read=20names=20come=20from=20the=20assigner=20client?= =?UTF-8?q?=20(ADR=200634=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/buckets/buckets.go | 12 +++++++++--- internal/compose/frombus/states.go | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index 878f4a9..00214c2 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -3,6 +3,12 @@ // reads through grants composed in code, so the holder's name is kept here, in its own code (ADR 0634 §1, finding F4). package buckets +import ( + assigner "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + wants "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" +) + // The node-engine's own states and the have state, written by every node's engine for its own keys. const ( // Have holds a module's have on a node, `.`, and the node's own facts, `.node`. @@ -20,9 +26,9 @@ const HaveHistory = 20 // module as every state is (ADR 0634 §1, finding F4: the holder's name is kept here, in the engine's code). const ( // Placement holds a node's placement, `.node`, and each of its modules, `.`. - Placement = "mesh-delivery-assigner_placement" + Placement = assigner.PlacementBucket // PlacementCandidates holds a node's candidate of a proposal, `.`. - PlacementCandidates = "mesh-delivery-assigner_candidates" + PlacementCandidates = assigner.Module + "_" + seat.StateCandidates // Wants holds what each node should run, `want..` and `want..node`. - Wants = "mesh-delivery-assigner_wants" + Wants = assigner.Module + "_" + wants.StateWants ) diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index 4c5dcab..2c5d38b 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -28,7 +28,7 @@ import ( const ( // The holders' names are kept here, in the engine's own code, as the names of the states a manifest's reads use // (ADR 0634 §1, finding F4); a seat's contract names a state locally, not the module that holds it. - PlacementRead = "mesh-delivery-assigner.placement" + PlacementRead = meshmoduleassignerclient.PlacementRead ModulesRead = "mesh-delivery-indexer.modules" ManifestsRead = "mesh-delivery-indexer.manifests" BuildsRead = "mesh-delivery-builder.builds" -- 2.54.0 From 10852161c90d44b678bc6621f1a036906c58f158 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:42:34 +0100 Subject: [PATCH 42/73] =?UTF-8?q?Node-engine:=20the=20catalogue's=20module?= =?UTF-8?q?s,=20manifests,=20verdicts=20and=20candidates=20are=20the=20mes?= =?UTF-8?q?h-catalog=20seat's=20types=20and=20its=20client's=20keys=20and?= =?UTF-8?q?=20codecs;=20the=20composer=20case=20is=20the=20delivery=20SDK'?= =?UTF-8?q?s,=20its=20verdicts=20and=20placements=20raw;=20the=20provided?= =?UTF-8?q?=20module=20is=20the=20private=20network's=20(ADR=200634=20?= =?UTF-8?q?=C2=A71,=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-compose/main_test.go | 10 +- cmd/mesh-host/nodeagent_integration_test.go | 59 ++++--- cmd/mesh-host/trust.go | 5 +- cmd/mesh-host/whatruns_test.go | 4 +- go.mod | 3 + internal/agent/agent_test.go | 3 +- internal/buckets/buckets.go | 25 +++ internal/buckets/buckets_test.go | 22 +++ internal/buckets/bucketstest/bucketstest.go | 26 +++ internal/compose/composed.go | 4 +- internal/compose/composed_test.go | 19 +-- internal/compose/frombus/answer_rows_test.go | 50 +++--- internal/compose/frombus/frombus.go | 36 ++-- internal/compose/frombus/frombus_test.go | 46 ++--- .../frombus/frombustest/frombustest.go | 26 +-- .../frombus/frombustest/frombustest_test.go | 20 ++- internal/compose/frombus/held_rows_test.go | 14 +- internal/compose/frombus/states.go | 16 +- internal/composer/composer.go | 53 +++--- internal/composer/composer_test.go | 16 +- internal/judge/answer_test.go | 5 +- internal/judge/judge_test.go | 5 +- internal/selfcompose/candidate.go | 54 +++--- internal/selfcompose/candidate_test.go | 107 ++++++------ internal/selfcompose/composedstate.go | 4 +- internal/selfcompose/composedstate_test.go | 30 ++-- internal/selfcompose/data_test.go | 11 +- internal/selfcompose/selfcompose.go | 50 +++--- internal/selfcompose/selfcompose_test.go | 160 +++++++++--------- internal/selfcompose/shadow.go | 19 +-- internal/userlist/userlist_test.go | 3 +- 31 files changed, 503 insertions(+), 402 deletions(-) diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index f70cf66..2ce1355 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -3,6 +3,9 @@ package main import ( "bytes" "encoding/json" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "github.com/novox/mesh-host/internal/buckets/bucketstest" "go/build" "os" "os/exec" @@ -14,7 +17,6 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/beside" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/composer" @@ -94,7 +96,7 @@ func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { r := request(t, composer.DepthResolve) r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)} r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)} - r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}} + r.Cases[0].Verdicts = bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"refused": {V: 1, Module: "refused", Judge: "src2:j", State: meshcatalogclient.VerdictRefused, Problems: []string{"it is wrong"}}}) code, v, stderr := runJSON(t, nil, encode(t, r)) if code != 0 { t.Fatalf("exit %d: %s", code, stderr) @@ -280,7 +282,7 @@ func ownPackages(t *testing.T) map[string][]string { } // 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard -// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the private network's overlay package (mesh-wireguard's own pure package: names, graph and +// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the delivery SDK's composer (the command's own request and verdict types), the private network's overlay package (mesh-wireguard's own pure package: names, graph and // generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or // another process. func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { @@ -292,7 +294,7 @@ func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { for path, imports := range own { for _, imp := range imports { std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") - if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { + if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/sdk/composer") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp) } for _, f := range forbidden { diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 9e6e94f..eda3474 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -11,6 +11,9 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -184,7 +187,7 @@ func (b *itBed) say(line string) { b.t.Log(line) } -func fpIt(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } +func fpIt(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) } func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) } @@ -411,7 +414,7 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { } return kv } - placement, modules, manifests := kvOf(buckets.Placement), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket) + placement, modules, manifests := kvOf(buckets.Placement), kvOf(buckets.Modules), kvOf(buckets.Manifests) resolved := kvOf(selfcompose.ResolvedBucket) builds := kvOf(delivery.BuilderBuildsBucket) candidates := kvOf(buckets.PlacementCandidates) @@ -421,11 +424,11 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { `"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0644","content":"greeting=${config:greeting}\n"},` + `{"id":"tools","type":"package","package":"web-tools"}],` + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` - digest := delivery.ManifestDigest([]byte(man)) - mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man}) - mkey, _ := delivery.ManifestsKey(digest) + digest := meshcatalogclient.ManifestDigest([]byte(man)) + mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "web", Manifest: man}) + mkey, _ := meshcatalogclient.ManifestsKey(digest) fp := fpIt("a") - modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)}) + modv, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)}) place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}) rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}}) // it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both. @@ -436,10 +439,10 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { // A provider on it2 serving from its own config value: a mail domain, which it1's composition settles. mailer := `{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` + `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}` - mdigest := delivery.ManifestDigest([]byte(mailer)) - mlraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "mailer", JSON: mailer}) - mlkey, _ := delivery.ManifestsKey(mdigest) - mlmod, _ := delivery.EncodeModule(delivery.Module{FP2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)}) + mdigest := meshcatalogclient.ManifestDigest([]byte(mailer)) + mlraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "mailer", Manifest: mailer}) + mlkey, _ := meshcatalogclient.ManifestsKey(mdigest) + mlmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)}) place2, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "it2", Modules: []meshmoduleassigner.PlacedModule{{Module: "mailer"}}}) rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}}) pkey2, _ := meshmoduleassignerclient.LegacyKey("it2") @@ -582,8 +585,8 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) { } return kv } - cands, _ := kvOf(delivery.CandidatesBucket), kvOf(delivery.VerdictsBucket) - manifests, err := b.js.KeyValue(delivery.ManifestsBucket) + cands, _ := kvOf(buckets.Candidates), kvOf(buckets.Verdicts) + manifests, err := b.js.KeyValue(buckets.Manifests) if err != nil { t.Fatal(err) } @@ -591,25 +594,25 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) { man := `{"module":"web","version":"2","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` + `"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0640","content":"greeting=${config:greeting}\n"},` + `{"id":"tools","type":"package","package":"web-tools"}]}` - digest := delivery.ManifestDigest([]byte(man)) - mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man}) - mkey, _ := delivery.ManifestsKey(digest) + digest := meshcatalogclient.ManifestDigest([]byte(man)) + mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "web", Manifest: man}) + mkey, _ := meshcatalogclient.ManifestsKey(digest) if _, err := manifests.Put(mkey, mraw); err != nil { t.Fatal(err) } - head := delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)} - heads, _ := delivery.HeadsHash([]delivery.Head{head}) - pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"}, - Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}} + head := meshdeliveryclient.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)} + heads, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{head}) + pulls := []meshcatalog.CandidatesByGroupValuePullsItem{bucketstest.Pull(head, []string{"web"}, + map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"web": {Fp2: fpIt("c"), Manifest: digest}})} // The indexer keys the candidate by its group (a branch name, slash allowed); the checker asks by the composition key. const group = "fix/3-web" - craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: heads, Pulls: pulls, - FPSet: delivery.FingerprintSet(pulls)}) + craw, err := bucketstest.GroupCandidate(meshcatalog.CandidatesByGroupValue{Group: group, Heads: heads, Pulls: pulls, + Fpset: meshcatalogclient.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } - ckey, _ := delivery.CandidatesKey(group) - key, _ := delivery.CompositionKey(group, heads) + ckey, _ := buckets.CandidateKey(group) + key, _ := nec.CompositionKey(group, heads) if _, err := cands.Put(ckey, craw); err != nil { t.Fatal(err) } @@ -765,7 +768,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test } return kv } - placement, modules, manifests := kvOf(buckets.Placement), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket) + placement, modules, manifests := kvOf(buckets.Placement), kvOf(buckets.Modules), kvOf(buckets.Manifests) kvOf(selfcompose.ResolvedBucket) builds := kvOf(delivery.BuilderBuildsBucket) kvOf(buckets.PlacementCandidates) @@ -776,10 +779,10 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test `"data":{"own":[{"id":"jetstream","path":"` + b.dir + `","class":"valuable","why":"the bus's streams"}]},"resources":[` + `{"id":"server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: new\n"},` + `{"id":"server","type":"container","name":"` + server + `","image":"` + image + `"` + env + `}]}` - digest := delivery.ManifestDigest([]byte(man)) - mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "nats", JSON: man}) - mkey, _ := delivery.ManifestsKey(digest) - modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)}) + digest := meshcatalogclient.ManifestDigest([]byte(man)) + mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "nats", Manifest: man}) + mkey, _ := meshcatalogclient.ManifestsKey(digest) + modv, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)}) if _, err := manifests.Put(mkey, mraw); err != nil { t.Fatal(err) } diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 74f7f10..4eb1e7d 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "github.com/novox/mesh-host/internal/buckets" "os" "path/filepath" @@ -244,8 +245,8 @@ func allHave(p meshmoduleassigner.Placement, has func(module string) (nodeengine return false, m.Module + " has no have yet" } // A module the mesh provides (mesh-wireguard) never has a fingerprint: the node's own composes it from the manifest - // mesh-sdk provides (delivery.ProvidedManifest), never from the kept declaration, so it keeps no read. - if h.Phase != nodeengineclient.PhaseRemoved && !strings.HasPrefix(h.Fp, delivery.SourcePrefix2) && !delivery.ProvidedByMesh(m.Module) { + // mesh-sdk provides (overlay.ProvidedManifest), never from the kept declaration, so it keeps no read. + if h.Phase != nodeengineclient.PhaseRemoved && !strings.HasPrefix(h.Fp, meshcatalogclient.SourcePrefix2) && !overlay.ProvidedByMesh(m.Module) { return false, m.Module + " runs at a build of the old form (" + h.Build + "), which only the kept declaration records" } } diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 17067bc..79bbda2 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -8,6 +8,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" engcomp "github.com/novox/mesh-host/internal/compositions" "path/filepath" "sort" @@ -16,7 +17,6 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/liveness" @@ -123,7 +123,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { placed := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} // Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the // node's own) keeps the kept declaration read, since it is the one record of what runs of it. - newForm := nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning} + newForm := nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning} oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning} for _, row := range []struct { name string diff --git a/go.mod b/go.mod index cbda85e..46b4065 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/sdk v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-20261105000000-000000000000 diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 8627176..46155a0 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -7,6 +7,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" "maps" @@ -24,7 +25,7 @@ import ( const node = "n1" -func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } +func fpOf(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) } // A watcher the test writes to; it says Caught once the keys written before the watch began are out. type feed struct { diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index 00214c2..53b0479 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -4,9 +4,13 @@ package buckets import ( + "errors" + assigner "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" wants "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" ) // The node-engine's own states and the have state, written by every node's engine for its own keys. @@ -32,3 +36,24 @@ const ( // Wants holds what each node should run, `want..` and `want..node`. Wants = assigner.Module + "_" + wants.StateWants ) + +// The states of the catalogue seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4). +const ( + // Modules holds a module's source, ``; Manifests a manifest by its digest; Verdicts the judge's verdict of a + // manifest by its digest; Candidates a group's candidate, by the group's key part (CandidateKey). + Modules = catalogHolder + "_modules" + Manifests = catalogHolder + "_manifests" + Verdicts = catalogHolder + "_verdicts" + Candidates = catalogHolder + "_candidates" + + catalogHolder = "mesh-delivery-indexer" +) + +// CandidateKey is the key of a group's candidate in Candidates: the group as a key part, a branch such as `fix/1.2` +// escaped so it keys. +func CandidateKey(group string) (string, error) { + if group == "" { + return "", errors.New("buckets: the group name is empty") + } + return catalog.CandidatesByGroupKey(keys.EscapeName(group)), nil +} diff --git a/internal/buckets/buckets_test.go b/internal/buckets/buckets_test.go index db981d6..d24750b 100644 --- a/internal/buckets/buckets_test.go +++ b/internal/buckets/buckets_test.go @@ -1,7 +1,10 @@ package buckets import ( + "strings" + "encoding/json" + "git.novox.be/novox/mesh-sdk/go/keys" "os" "testing" ) @@ -32,3 +35,22 @@ func TestTheOwnStatesAreTheManifestsStates(t *testing.T) { } } } + +// A group's candidate key is its name as a key part: a branch with a slash keeps it, a dot is escaped, and an empty group does not. +func TestACandidateKeyEscapesTheGroup(t *testing.T) { + for _, group := range []string{"mesh-sdk#7", "fix/940-no-retire", "fix/1.2"} { + key, err := CandidateKey(group) + if err != nil { + t.Fatalf("%s: %v", group, err) + } + if strings.Contains(key, ".") { + t.Errorf("the key %q of %q holds a dot", key, group) + } + if back, err := keys.UnescapeName(key); err != nil || back != group { + t.Errorf("%q came back as %q (%v)", group, back, err) + } + } + if _, err := CandidateKey(""); err == nil { + t.Error("a key was made for no group") + } +} diff --git a/internal/buckets/bucketstest/bucketstest.go b/internal/buckets/bucketstest/bucketstest.go index 9848e23..03912cc 100644 --- a/internal/buckets/bucketstest/bucketstest.go +++ b/internal/buckets/bucketstest/bucketstest.go @@ -4,6 +4,8 @@ package bucketstest import ( "encoding/json" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" @@ -34,3 +36,27 @@ func Candidate(c meshmoduleassigner.CandidatesByProposalNodeValue) ([]byte, erro } return json.Marshal(c) } + +// GroupCandidate is a group's candidate as the catalogue seat's holder writes it. +func GroupCandidate(c meshcatalog.CandidatesByGroupValue) ([]byte, error) { + c.V = Version + return json.Marshal(c) +} + +// Pull is one pull request of a candidate, from its head. +func Pull(head meshdeliveryclient.Head, modules []string, fps map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue) meshcatalog.CandidatesByGroupValuePullsItem { + return meshcatalog.CandidatesByGroupValuePullsItem{Repository: head.Repository, Number: int64(head.Number), Head: head.Head, Modules: modules, Fps: fps} +} + +// Raw is each value as JSON, as a composer case carries verdicts and placements. +func Raw[V any](in map[string]V) map[string]json.RawMessage { + out := map[string]json.RawMessage{} + for k, v := range in { + raw, err := json.Marshal(v) + if err != nil { + panic(err) + } + out[k] = raw + } + return out +} diff --git a/internal/compose/composed.go b/internal/compose/composed.go index c30b539..a045795 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "sort" @@ -12,7 +13,6 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/resolution" ) @@ -70,7 +70,7 @@ func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (ou // not folded (issue 1585: nats on novox, folded by the reader and composed at src2: here, refused for its server // artifact no build had published). func Folds(inOwn bool, fp string) bool { - return inOwn && !strings.HasPrefix(fp, delivery.SourcePrefix2) + return inOwn && !strings.HasPrefix(fp, meshcatalogclient.SourcePrefix2) } func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]engcomp.Composed, skipped, stood []string, err error) { diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index ccbb3d4..c33c749 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -2,6 +2,7 @@ package compose import ( "encoding/json" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" engcomp "github.com/novox/mesh-host/internal/compositions" "sort" "strings" @@ -9,11 +10,10 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/resolution" ) -func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } +func fpFor(c byte) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64) } // 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs // to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on @@ -186,18 +186,3 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { } } } - -// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words. -func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) { - for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} { - for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, "the bus", catalogue.FromData, catalogue.FromDefault} { // "the bus": an older reader's word, still read - v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from} - if _, err := delivery.EncodeComposedDigest(v); err != nil { - t.Errorf("%s from %s: %v", p, from, err) - } - } - } - if delivery.PolicyRecord != catalogue.PolicyRecord { - t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord) - } -} diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index e1e2747..ebaaf31 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -7,6 +7,8 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -45,24 +47,24 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } b := &bus{placements: map[string]meshmoduleassigner.Placement{"n1": p}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, - modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, - manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, + modules: map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}}, + manifests: map[string][]byte{meshcatalogclient.ManifestDigest(lemurs): lemurs}, builds: map[string]*delivery.BuilderBuild{}} for _, m := range []string{"web", "api", "spiegel"} { b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) } for _, m := range []string{"web", "api", "spiegel"} { raw := manifest(m) - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw + b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } // web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of // the new form at all. for i, m := range []string{"web", "api"} { mod := b.modules[m] - mod.FP2 = fp(string(rune('1' + i))) + mod.Fp2 = fp(string(rune('1' + i))) b.modules[m] = mod - b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + b.builds[m+" "+mod.Fp2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}} } return b @@ -204,16 +206,16 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { bp.Decided.Endpoint = "n1.example:51820" bb.placements["n1"] = bp sp := bb.modules["spiegel"] - sp.FP2 = fp("3") + sp.Fp2 = fp("3") bb.modules["spiegel"] = sp image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64) - bb.builds["spiegel "+sp.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: sp.Manifest, + bb.builds["spiegel "+sp.Fp2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: sp.Manifest, Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}} read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, - map[string]string{"lemurs": fp("e"), "spiegel": sp.FP2}) + map[string]string{"lemurs": fp("e"), "spiegel": sp.Fp2}) raw, _ := json.Marshal(read.Catalogue["spiegel"]) if err != nil || read.ComposedHow.Held["spiegel"] != "" || !strings.Contains(string(raw), strings.Repeat("7", 64)) { - t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.FP2, err, + t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.Fp2, err, read.ComposedHow, raw) } } @@ -232,8 +234,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { `"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`) b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault") for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} { - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw + b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } p = b.placements["n1"] p.Decided.Endpoint = "n1.example:51820" @@ -370,8 +372,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { `"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`) b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel") for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} { - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw + b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } p = b.placements["n1"] p.Decided.Endpoint = "n1.example:51820" @@ -470,8 +472,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`) b = mk("lemurs", overlay.Name, "site", "nft") for m, raw := range map[string][]byte{"site": site, "nft": nft} { - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw + b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } p = b.placements["n1"] p.Decided.Endpoint = "n1.example:51820" @@ -511,8 +513,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { `"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`) b2 := mk("lemurs", overlay.Name, "site", "nft", "app") for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} { - b2.manifests[delivery.ManifestDigest(raw)] = raw - b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + b2.manifests[meshcatalogclient.ManifestDigest(raw)] = raw + b2.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } p2 := b2.placements["n1"] p2.Decided.Endpoint = "n1.example:51820" @@ -554,16 +556,16 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { // build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build // that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090. moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1)) - b.manifests[delivery.ManifestDigest(moved)] = moved - b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)} + b.manifests[meshcatalogclient.ManifestDigest(moved)] = moved + b.modules["site"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(moved)} for i := range p.Modules { p.Modules[i].Ports = nil } b.placements["n1"] = p siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) siteRuns.FP = fp("7") - b.manifests[delivery.ManifestDigest(site)] = site - b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)} + b.manifests[meshcatalogclient.ManifestDigest(site)] = site + b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(site)} got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps) if err != nil || held["site"] == "" || held["nft"] != "" { t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held) @@ -593,8 +595,8 @@ func otherNodeNotResolvedIsNamed(t *testing.T) { "n1": {Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, "n2": {Node: "n2", Decided: meshmoduleassigner.NodeDecided{Address: "10.77.0.2"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, - modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, - manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} + modules: map[string]meshcatalog.ModulesByModuleValue{"prov": {Manifest: meshcatalogclient.ManifestDigest(prov)}, "cons": {Manifest: meshcatalogclient.ManifestDigest(cons)}}, + manifests: map[string][]byte{meshcatalogclient.ManifestDigest(prov): prov, meshcatalogclient.ManifestDigest(cons): cons}} given := map[string]*meshmoduleassigner.Placement{} for n, p := range b.placements { p := p diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 27030e9..899e057 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -38,6 +38,8 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "net/netip" @@ -62,9 +64,9 @@ type Reader interface { // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of // buckets.Have (ADR 0555 §3). Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error) - // Modules is every module the indexer holds, by module (delivery.ModulesBucket). - Modules(ctx context.Context) (map[string]delivery.Module, error) - // Manifest is the raw manifest of a digest (delivery.ManifestsBucket). + // Modules is every module the indexer holds, by module (buckets.Modules). + Modules(ctx context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) + // Manifest is the raw manifest of a digest (buckets.Manifests). Manifest(ctx context.Context, digest string) ([]byte, error) // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`, // keys `..`), decoded; nil when it has none. @@ -198,7 +200,7 @@ func InOwn(running []engcomp.Composed, p meshmoduleassigner.Placement) []string // Input is the composition input for s, read through r. // // **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently -// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing +// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (meshcatalogclient.TakeVerdict, a missing // one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the // catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in // the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on. @@ -209,10 +211,10 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er // taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict // from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a // manifest not read past a field it set aside holds its name alone and is held the same. -func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) { - if v := m.VerdictOf(name); v != nil { - if refused, _ := delivery.TakeManifestVerdict(v); refused { - return unusable(name, raw), v.Why() +func taken(name string, raw []byte, m meshcatalog.ModulesByModuleValue) (man catalogue.Manifest, why string) { + if v := meshcatalogclient.ModuleVerdict(name, m); v != nil { + if refused, _ := meshcatalogclient.TakeVerdict(v); refused { + return unusable(name, raw), meshcatalogclient.VerdictWhy(*v) } } man, err := catalogue.Decode(raw) @@ -256,7 +258,7 @@ type base struct { in compose.Input placements map[string]meshmoduleassigner.Placement records map[string]nodeengine.NodeRecord - modules map[string]delivery.Module + modules map[string]meshcatalog.ModulesByModuleValue // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order // read: every scope holds it. ownRefused [][2]string @@ -383,7 +385,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { // The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes // with mesh-sdk and always wins, as the controller's providedModules and the grants part have it. - for _, name := range delivery.ProvidedModules() { + for _, name := range overlay.ProvidedModules() { raw, err := json.Marshal(overlay.Manifest()) if err != nil { return b, err @@ -794,7 +796,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, // by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it // (issue 1126). func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), - in *compose.Input, s Scope, p meshmoduleassigner.Placement, modules map[string]delivery.Module, + in *compose.Input, s Scope, p meshmoduleassigner.Placement, modules map[string]meshcatalog.ModulesByModuleValue, read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), refuse func(module string, err error), wanted map[string]string) error { // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides @@ -881,7 +883,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m // the scope keeps at another build is. keptAt := maps.Clone(s.KeptAt) for _, pm := range p.Modules { - if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 && + if m, ok := modules[pm.Module]; ok && m.Fp2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.Fp2 && keptAt[pm.Module] == "" && !compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) { if keptAt == nil { keptAt = map[string]string{} @@ -897,7 +899,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m } for _, pm := range p.Modules { m, ok := modules[pm.Module] - if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) { + if !ok || m.Fp2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) { // A module it runs inside its own composition is composed there as before while it is folded: no build of the // new form is what it runs (Scope.InOwn). Wanted at a build of the new form it is no longer folded (compose.Folds, // the composition's own rule), so its build is taken in (issue 1585). @@ -909,9 +911,9 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) { continue // nothing is built of it: its manifest is what it composes from } - b, err := build(ctx, pm.Module, m.FP2) + b, err := build(ctx, pm.Module, m.Fp2) if err != nil { - err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err) + err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.Fp2, err) if refuse == nil { return err } @@ -924,11 +926,11 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m // runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30: // node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused). stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+ - "keeps running what it runs", pm.Module, m.FP2)) + "keeps running what it runs", pm.Module, m.Fp2)) } continue // nothing of it runs: composed from the indexer's manifest, as before } - resolved, err := built(pm.Module, m.FP2, b) + resolved, err := built(pm.Module, m.Fp2, b) if err != nil { if refuse == nil { return err diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 8b112ba..33a75fe 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -15,6 +15,8 @@ import ( nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -35,7 +37,7 @@ type bus struct { handed map[string]bool placements map[string]meshmoduleassigner.Placement records map[string]nodeengine.NodeRecord - modules map[string]delivery.Module + modules map[string]meshcatalog.ModulesByModuleValue manifests map[string][]byte values map[string]map[string]any // by node.module valuesRead []string @@ -89,7 +91,9 @@ func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed func (b *bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { return b.records, nil } -func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } +func (b *bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) { + return b.modules, nil +} func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { raw, ok := b.manifests[digest] if !ok { @@ -161,13 +165,13 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - digest := delivery.ManifestDigest(xterm) + digest := meshcatalogclient.ManifestDigest(xterm) // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` + `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) - mediaDigest := delivery.ManifestDigest(media) + mediaDigest := meshcatalogclient.ManifestDigest(media) keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} caps := []string{"container-runtime", "systemd", "wireguard"} tunnel := &meshmoduleassigner.FoundTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", @@ -189,7 +193,7 @@ func TestInput(t *testing.T) { "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, }, - modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}}, + modules: map[string]meshcatalog.ModulesByModuleValue{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}}, manifests: map[string][]byte{digest: xterm, mediaDigest: media}, values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}, "laptop.media": {"lines": "not mesh-config's"}}, @@ -337,10 +341,10 @@ func TestInput(t *testing.T) { } return &bus{placements: map[string]meshmoduleassigner.Placement{"n1": p}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, - modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, - "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, - manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, - delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files}, + modules: map[string]meshcatalog.ModulesByModuleValue{"keeper": {Manifest: meshcatalogclient.ManifestDigest(keeper)}, "lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}, + "objects": {Manifest: meshcatalogclient.ManifestDigest(objects)}, "files": {Manifest: meshcatalogclient.ManifestDigest(files)}}, + manifests: map[string][]byte{meshcatalogclient.ManifestDigest(keeper): keeper, meshcatalogclient.ManifestDigest(lemurs): lemurs, + meshcatalogclient.ManifestDigest(objects): objects, meshcatalogclient.ManifestDigest(files): files}, own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}} } oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64), @@ -609,7 +613,7 @@ func TestInput(t *testing.T) { if err := zw.Close(); err != nil { t.Fatal(err) } - archiveDigest := delivery.ManifestDigest(packed.Bytes()) + archiveDigest := meshcatalogclient.ManifestDigest(packed.Bytes()) digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), "store": "src2:" + strings.Repeat("5", 64)} @@ -625,19 +629,19 @@ func TestInput(t *testing.T) { rec := b.records["n1"] rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"} b.records["n1"] = rec - b.modules = map[string]delivery.Module{} + b.modules = map[string]meshcatalog.ModulesByModuleValue{} b.manifests = map[string][]byte{} for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} { - b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)} - b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[module] = meshcatalog.ModulesByModuleValue{Fp2: movedFps[module], Manifest: meshcatalogclient.ManifestDigest(raw)} + b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw } // The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out. b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} if built { b.builds = map[string]*delivery.BuilderBuild{ - "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, - "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, - "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, + "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, + "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, + "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)}, } b.archives = map[string][]byte{archiveDigest: packed.Bytes()} } @@ -770,8 +774,8 @@ func TestInput(t *testing.T) { p := b.placements["anchor"] p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "mailer"}) b.placements["anchor"] = p - b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)} - b.manifests[delivery.ManifestDigest(mailer)] = mailer + b.modules["mailer"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(mailer)} + b.manifests[meshcatalogclient.ManifestDigest(mailer)] = mailer if set { b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}} } @@ -852,12 +856,12 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { } modules, _ := b.Modules(ctx) for name, m := range modules { - raw, err := delivery.EncodeModule(m) + raw, err := meshcatalogclient.EncodeModule(m) put(ModulesRead, name, raw, err) var mf struct{ Module string } _ = json.Unmarshal(b.manifests[m.Manifest], &mf) - k, _ := delivery.ManifestsKey(m.Manifest) - raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])}) + k, _ := meshcatalogclient.ManifestsKey(m.Manifest) + raw, err = meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: mf.Module, Manifest: string(b.manifests[m.Manifest])}) put(ManifestsRead, k, raw, err) } for key, build := range b.builds { diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go index beda4ff..e0016d0 100644 --- a/internal/compose/frombus/frombustest/frombustest.go +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -14,6 +14,8 @@ import ( "errors" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "strings" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -28,7 +30,7 @@ var testdata embed.FS type Bus struct { Placement map[string]meshmoduleassigner.Placement Record map[string]nodeengine.NodeRecord - Module map[string]delivery.Module + Module map[string]meshcatalog.ModulesByModuleValue Manifests map[string][]byte Value map[string]map[string]any // by node.module Own []compose.OwnSecretRow @@ -46,8 +48,10 @@ func (b *Bus) Placements(context.Context) (map[string]meshmoduleassigner.Placeme func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { return b.Record, nil } -func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil } -func (b *Bus) VaultAuthority(context.Context) string { return "" } +func (b *Bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) { + return b.Module, nil +} +func (b *Bus) VaultAuthority(context.Context) string { return "" } func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) { raw, ok := b.Manifests[digest] @@ -149,7 +153,7 @@ func Provisioned() (*Bus, error) { if err := zw.Close(); err != nil { return nil, err } - archive := delivery.ManifestDigest(packed.Bytes()) + archive := meshcatalogclient.ManifestDigest(packed.Bytes()) digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } fps := FPs() b := &Bus{ @@ -158,24 +162,24 @@ func Provisioned() (*Bus, error) { Modules: []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, - Module: map[string]delivery.Module{}, Manifests: map[string][]byte{}, + Module: map[string]meshcatalog.ModulesByModuleValue{}, Manifests: map[string][]byte{}, Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}, Builds: map[string]*delivery.BuilderBuild{ - "restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{ + "restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{ {Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}}, - "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), + "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}}, - "store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, - "postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)}, + "store " + fps["store"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)}, + "postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(postgres)}, }, Archives: map[string][]byte{archive: packed.Bytes()}, Held: true, } for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} { - b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)} - b.Manifests[delivery.ManifestDigest(raw)] = raw + b.Module[module] = meshcatalog.ModulesByModuleValue{Fp2: fps[module], Manifest: meshcatalogclient.ManifestDigest(raw)} + b.Manifests[meshcatalogclient.ManifestDigest(raw)] = raw } return b, nil } diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index a071e3b..e0ac451 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -7,6 +7,8 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" engcomp "github.com/novox/mesh-host/internal/compositions" "reflect" "slices" @@ -72,15 +74,15 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "backup"}) b.Placement[Node] = p } - digest := delivery.ManifestDigest([]byte(row.bad)) + digest := meshcatalogclient.ManifestDigest([]byte(row.bad)) b.Manifests[digest] = []byte(row.bad) - b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest} + b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest} if row.refused != "" { - b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused, + b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Verdict: meshcatalogclient.VerdictRefused, Problems: []string{row.refused}} } if row.built != "" { - builtDigest := delivery.ManifestDigest([]byte(row.built)) + builtDigest := meshcatalogclient.ManifestDigest([]byte(row.built)) b.Manifests[builtDigest] = []byte(row.built) b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} @@ -265,7 +267,7 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { delete(r.(map[string]any), "user") } raw, _ := json.Marshal(m) - mod.Manifest = delivery.ManifestDigest(raw) + mod.Manifest = meshcatalogclient.ManifestDigest(raw) bb.Module["mesh-provisioner-client"], bb.Manifests[mod.Manifest] = mod, raw built := *bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] built.Manifest = mod.Manifest @@ -321,13 +323,13 @@ func statesOf(t *testing.T, b *Bus) frombus.States { put(frombus.HaveRead, node+".node", raw, err) } for module, m := range b.Module { - key, _ := delivery.ModulesKey(module) - raw, err := delivery.EncodeModule(m) + key, _ := meshcatalogclient.ModulesKey(module) + raw, err := meshcatalogclient.EncodeModule(m) put(frombus.ModulesRead, key, raw, err) } for digest, man := range b.Manifests { - key, _ := delivery.ManifestsKey(digest) - raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)}) + key, _ := meshcatalogclient.ManifestsKey(digest) + raw, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "m", Manifest: string(man)}) put(frombus.ManifestsRead, key, raw, err) } for at, build := range b.Builds { diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index 09ab4c8..a60ec47 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -7,6 +7,8 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "testing" @@ -21,13 +23,13 @@ func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { t.Helper() p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, - modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} + modules: map[string]meshcatalog.ModulesByModuleValue{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} fps := map[string]string{} for i, raw := range manifests { module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0] p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: module}) - b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))} - b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw) + b.modules[module] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest([]byte(raw))} + b.manifests[meshcatalogclient.ManifestDigest([]byte(raw))] = []byte(raw) fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64) } b.placements = map[string]meshmoduleassigner.Placement{"n1": p} @@ -51,7 +53,7 @@ func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) { // refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1). func refuse(b *bus, module string, problems ...string) { m := b.modules[module] - m.Verdict, m.Problems = delivery.VerdictRefused, problems + m.Verdict, m.Problems = meshcatalogclient.VerdictRefused, problems b.modules[module] = m } @@ -144,7 +146,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { b.held = true // every grant held, so a contribution's credential stands in for nothing b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} mod := b.modules["node-tools"] - mod.FP2 = next + mod.Fp2 = next b.modules["node-tools"] = mod b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}} @@ -254,7 +256,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { name = "runtime" } mod := b.modules[m] - mod.FP2 = fps[m] + mod.Fp2 = fps[m] b.modules[m] = mod b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}} diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index 2c5d38b..501005a 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -16,6 +16,8 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "strings" "time" @@ -102,11 +104,11 @@ func (r statesReader) Records(context.Context) (map[string]nodeengine.NodeRecord return out, err } -func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) { - out := map[string]delivery.Module{} +func (r statesReader) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) { + out := map[string]meshcatalog.ModulesByModuleValue{} err := r.each(ModulesRead, func(k string, v []byte) { - if module, err := delivery.ParseModulesKey(k); err == nil { - if m, err := delivery.DecodeModule(v); err == nil { + if module, err := meshcatalogclient.ParseModulesKey(k); err == nil { + if m, err := meshcatalogclient.DecodeModule(v); err == nil { out[module] = m } } @@ -115,7 +117,7 @@ func (r statesReader) Modules(context.Context) (map[string]delivery.Module, erro } func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) { - key, err := delivery.ManifestsKey(digest) + key, err := meshcatalogclient.ManifestsKey(digest) if err != nil { return nil, err } @@ -126,11 +128,11 @@ func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) if !found { return nil, fmt.Errorf("the indexer holds no manifest %s", digest) } - m, err := delivery.DecodeManifest(v) + m, err := meshcatalogclient.DecodeManifest(v) if err != nil { return nil, err } - return []byte(m.JSON), nil + return []byte(m.Manifest), nil } func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) { diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 984bbcd..3e5f3f2 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -10,14 +10,16 @@ package composer import ( "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "slices" "sort" "strings" + sdk "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/catalogue" - sdk "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/sdkversion" "github.com/novox/mesh-host/internal/compose" @@ -92,7 +94,7 @@ func Run(r Request) (Verdict, error) { // parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A // manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes // that do not decode are left out. Both are named in reads. -func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) { +func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]json.RawMessage) (map[string]catalogue.Manifest, error) { out := map[string]catalogue.Manifest{} for name, man := range m { if len(man.Raw) == 0 { @@ -102,12 +104,16 @@ func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[st read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()} if err != nil { read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err) - } else if v, ok := verdicts[name]; ok { - if refused, _ := delivery.TakeManifestVerdict(&v); refused { + } else if raw, ok := verdicts[name]; ok { + v, verr := meshcatalogclient.DecodeVerdict(raw) + if verr != nil { + return nil, fmt.Errorf("composer: the verdict of %s is not one: %w", name, verr) + } + if refused, _ := meshcatalogclient.TakeVerdict(&v); refused { if v.Module == "" { v.Module = name } - read = ManifestRead{Refused: v.Why()} + read = ManifestRead{Refused: meshcatalogclient.VerdictWhy(v)} decoded.Items = nil decoded = compose.Stub(decoded) } @@ -132,9 +138,17 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { if err != nil { return CaseVerdict{}, err } - placements, err := interimPlacements(c.Placements) - if err != nil { - return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) + placements := map[string]*meshmoduleassigner.Placement{} + for node, raw := range c.Placements { + if string(raw) == "null" { + placements[node] = nil + continue + } + p, perr := meshmoduleassignerclient.DecodePlacement(raw) + if perr != nil { + return CaseVerdict{}, fmt.Errorf("composer: the case %q: the placement of %s is not one: %w", c.Name, node, perr) + } + placements[node] = &p } in, err := frombus.Placed(base, placements) if err != nil { @@ -143,8 +157,8 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { // The module the mesh provides itself (the private network) has no repository the indexer reads, so no case carries // it: its manifest comes with mesh-sdk and always wins, as the bus reader has it (frombus readBase). Without it a node // it is assigned to composed nothing (issue 1126, check of mesh-host #200: "nothing provides mesh-wireguard"). - for _, name := range delivery.ProvidedModules() { - m, _, err := delivery.ProvidedManifest(name) + for _, name := range overlay.ProvidedModules() { + m, _, err := overlay.ProvidedManifest(name) if err != nil { return CaseVerdict{}, fmt.Errorf("composer: %w", err) } @@ -345,20 +359,3 @@ func SentInput(in compose.Input, node string) compose.Input { } return in } - -// interimPlacements carries the case's placements, typed as mesh-sdk's composer still types them, over to the seat's -// type through JSON. It goes when this package takes the case from the delivery SDK's composer, which holds them raw. -func interimPlacements(in map[string]*delivery.Placement) (map[string]*meshmoduleassigner.Placement, error) { - if in == nil { - return nil, nil - } - raw, err := json.Marshal(in) - if err != nil { - return nil, err - } - var out map[string]*meshmoduleassigner.Placement - if err := json.Unmarshal(raw, &out); err != nil { - return nil, err - } - return out, nil -} diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index a2a2101..c9ff7e1 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -3,6 +3,11 @@ package composer import ( "context" "encoding/json" + "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" + "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -10,7 +15,6 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/frombus/frombustest" @@ -55,20 +59,20 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { placed := withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthResolve, Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}, {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}, - Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}}, + Placements: bucketstest.Raw(map[string]meshmoduleassigner.Placement{"a": {V: 1, Node: "a", Modules: []meshmoduleassigner.PlacedModule{{Module: "ghost"}}}})}}}, compose.Input{Nodes: []compose.Node{{Name: "a"}}}) if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes || !strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") { t.Errorf("a case composes its own placement: %+v, %v", v, err) } - placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}} + placed.Cases[1].Placements = bucketstest.Raw(map[string]meshmoduleassigner.Placement{"z": {V: 1, Node: "z"}}) if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") { t.Errorf("a case placing a node the input lacks: %v", err) } // Issue 1126 (check of mesh-host #200): the module the mesh provides itself (the private network) comes from mesh-sdk, // a second source beside the indexer's manifests the cases carry, and the input carries no catalogue: a node it is // assigned to still composes, and what it provides is provided. - wg := delivery.ProvidedModules()[0] + wg := overlay.ProvidedModules()[0] provided := withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthResolve, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}, compose.Input{Nodes: []compose.Node{{Name: "a", Assigned: []string{wg, "demo"}}}}) @@ -245,8 +249,8 @@ func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { if got := v.Cases[0].Manifests["xterm"]; !got.Accepted { t.Fatalf("xterm: %+v, want accepted", got) } - r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"xterm": {Module: "xterm", State: delivery.VerdictRefused, - Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}} + r.Cases[0].Verdicts = bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"xterm": {V: 1, Module: "xterm", State: meshcatalogclient.VerdictRefused, + Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}}) shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts) if err != nil || !shelf["xterm"].Stubbed { t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err) diff --git a/internal/judge/answer_test.go b/internal/judge/answer_test.go index bdd7d9f..70e0cc7 100644 --- a/internal/judge/answer_test.go +++ b/internal/judge/answer_test.go @@ -2,16 +2,15 @@ package judge import ( nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "github.com/novox/mesh-host/internal/compositions" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // An answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay, and the answer // given is not changed (moved from mesh-sdk's placement test with FitAnswer, novox/hq ADR 0593 §7). func TestFitAnswer(t *testing.T) { - long := strings.Repeat("x", delivery.MaxBody) + long := strings.Repeat("x", compositions.MaxBody) big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictDiffers, Differs: []ModuleDiffers{{ Module: "nats", Why: "differs", Bodies: []ResourceBodies{{ID: "conf", Why: "changed", Paths: []string{"content.links.1", "content.password"}, Composed: long, Running: long}}}}} diff --git a/internal/judge/judge_test.go b/internal/judge/judge_test.go index 2a5b7e3..12829b9 100644 --- a/internal/judge/judge_test.go +++ b/internal/judge/judge_test.go @@ -3,14 +3,13 @@ package judge import ( "encoding/json" "fmt" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) func composedOf(module string, c byte, data bool, resources ...string) Composed { - out := Composed{Node: "ace", Module: module, FP: delivery.SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} + out := Composed{Node: "ace", Module: module, FP: meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} for _, r := range resources { out.Resources = append(out.Resources, json.RawMessage(r)) } diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index c56f6b4..023db96 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -7,6 +7,9 @@ import ( "errors" "fmt" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "slices" @@ -16,7 +19,6 @@ import ( nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/compose/frombus" @@ -25,7 +27,7 @@ import ( // **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 §7): the node seat's verb compose-candidate. // Given the check's composition key (`.`), it reads the group's candidate from the indexer -// (delivery.CandidatesBucket, keyed by the group alone, delivery.CandidatesKey) at those heads, and composes itself +// (buckets.Candidates, keyed by the group alone, buckets.CandidateKey) at those heads, and composes itself // twice from what its shadow holds (placement, records, the indexer's modules, its config values), with its own sealed // values: once as it is, and once with the candidate's modules laid over the indexer's (each at the candidate's // fingerprint, its manifest by digest and the indexer's verdict on it, ADR 0578 §3; a module the candidate removes left @@ -141,13 +143,13 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi a.Verdict = nec.Unready return a } - group, heads, err := delivery.ParseCompositionKey(ask.Candidate) + group, heads, err := nec.ParseCompositionKey(ask.Candidate) if err != nil { return refuse(err.Error()) } // The ask names the check (the composition key, `.`); the indexer keys the group's candidate by - // the group alone (delivery.CandidatesKey) and holds its heads hash in the value. - ckey, err := delivery.CandidatesKey(group) + // the group alone (buckets.CandidateKey) and holds its heads hash in the value. + ckey, err := buckets.CandidateKey(group) if err != nil { return refuse(err.Error()) } @@ -155,21 +157,21 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi return unready(errNotReady.Error()) } base := run.add(c.scratch()) - raw, err := base.get(ctx, delivery.CandidatesBucket, ckey) + raw, err := base.get(ctx, buckets.Candidates, ckey) if err != nil { return unready(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) } if raw == nil { return refuse("the indexer holds no candidate " + ask.Candidate) } - cand, err := delivery.DecodeCandidate(raw) + cand, err := meshcatalog.DecodeCandidatesByGroupValue(raw) if err != nil { return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) } - if cand.HeadsHash != heads { + if cand.Heads != heads { // The group moved to other heads: the check asked is replaced, and this node composes not a candidate it was // not asked about. No answer (ADR 0593 §9), never a verdict on the wrong heads. - return unready(fmt.Sprintf("the indexer's candidate of the group %s is at heads %s, not %s: the check asked is replaced", group, cand.HeadsHash, heads)) + return unready(fmt.Sprintf("the indexer's candidate of the group %s is at heads %s, not %s: the check asked is replaced", group, cand.Heads, heads)) } // Every module's manifest is read before either composition, several at once, into the shadow's own: a composition // reads every manifest in the mesh one by one, which off the control-node took about 35 s each (issue 1126). @@ -332,7 +334,7 @@ func (c *Composer) scratch() *Composer { // warm reads, several at once, each manifest the indexer's modules and the candidate name that the shadow (origin) does // not hold yet, keeping each in origin and in c; one not read is left to the composition, which reads it and says why. // Its reads are not noted as the bus failing: the composition that reads again notes its own. -func (c *Composer) warm(ctx context.Context, origin *Composer, cand delivery.Candidate) { +func (c *Composer) warm(ctx context.Context, origin *Composer, cand meshcatalog.CandidatesByGroupValue) { origin.mu.Lock() want := map[string]bool{} for _, m := range origin.modules { @@ -351,11 +353,11 @@ func (c *Composer) warm(ctx context.Context, origin *Composer, cand delivery.Can if len(want) == 0 { return } - defer c.waiting(fmt.Sprintf("%d of the indexer's manifests (%s)", len(want), delivery.ManifestsBucket))() + defer c.waiting(fmt.Sprintf("%d of the indexer's manifests (%s)", len(want), buckets.Manifests))() slots := make(chan struct{}, warmAtOnce) var wg sync.WaitGroup for digest := range want { - key, err := delivery.ManifestsKey(digest) + key, err := meshcatalogclient.ManifestsKey(digest) if err != nil { continue } @@ -368,15 +370,15 @@ func (c *Composer) warm(ctx context.Context, origin *Composer, cand delivery.Can return } defer func() { <-slots }() - value, err := c.env.Get(ctx, delivery.ManifestsBucket, key) + value, err := c.env.Get(ctx, buckets.Manifests, key) if err != nil || value == nil { return } - m, err := delivery.DecodeManifest(value) + m, err := meshcatalogclient.DecodeManifest(value) if err != nil { return } - c.keepRead(func(k *Composer) { k.manifests[digest] = []byte(m.JSON) }) + c.keepRead(func(k *Composer) { k.manifests[digest] = []byte(m.Manifest) }) }() } wg.Wait() @@ -385,31 +387,31 @@ func (c *Composer) warm(ctx context.Context, origin *Composer, cand delivery.Can // layCandidate lays the candidate's modules over the indexer's: each at its fingerprint, with its manifest's digest and // the indexer's verdict on it (read from its verdicts, none read taken as accepted); a module the candidate removes goes. // It answers the modules laid. -func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (map[string]bool, error) { +func (c *Composer) layCandidate(ctx context.Context, cand meshcatalog.CandidatesByGroupValue) (map[string]bool, error) { touched := map[string]bool{} for _, pull := range cand.Pulls { for module, cm := range pull.Fps { - if cm.None2 == delivery.CandidateNoneNoModule { + if cm.None2 == candidateNoneNoModule { c.mu.Lock() delete(c.modules, module) c.mu.Unlock() continue } - if cm.FP2 == "" { + if cm.Fp2 == "" { continue // no fingerprint at the preview: composed as the trunk holds it } - m := delivery.Module{FP2: cm.FP2, Manifest: cm.Manifest, Repository: pull.Repository, Commit: pull.Head.Head} + m := meshcatalog.ModulesByModuleValue{Fp2: cm.Fp2, Manifest: cm.Manifest, Repository: pull.Repository, Commit: pull.Head} if cm.Manifest != "" { - key, err := delivery.VerdictsKey(cm.Manifest) + key, err := meshcatalogclient.VerdictsKey(cm.Manifest) if err != nil { return nil, err } - raw, err := c.get(ctx, delivery.VerdictsBucket, key) + raw, err := c.get(ctx, buckets.Verdicts, key) if err != nil { return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err) } if raw != nil { - v, err := delivery.DecodeManifestVerdict(raw) + v, err := meshcatalogclient.DecodeVerdict(raw) if err != nil { return nil, fmt.Errorf("the indexer's verdict on %s's manifest cannot be read: %w", module, err) } @@ -442,12 +444,12 @@ func (c *Composer) composeShadow(ctx context.Context, running []engcomp.Composed // A module the laid candidate changes is composed at the candidate's build, though the node runs another. for module := range fps { if mod, ok := modules[module]; ok && laid[module] { - fps[module] = mod.FP2 + fps[module] = mod.Fp2 } } kept := map[string]string{} for module, fp := range fps { - if mod, ok := modules[module]; ok && mod.FP2 == fp { + if mod, ok := modules[module]; ok && mod.Fp2 == fp { continue } kept[module] = fp @@ -472,3 +474,7 @@ func (c *Composer) composeShadow(ctx context.Context, running []engcomp.Composed } return comps, held, nil } + +// candidateNoneNoModule is the words the indexer writes in a candidate's module that the merge preview names no manifest +// for: the change removes the module. It is the indexer's text, kept here until the catalogue client names it. +const candidateNoneNoModule = "the merge preview names no manifest for the module's directory" diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index 1d481a0..e659478 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -5,7 +5,11 @@ import ( "encoding/json" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/novox/mesh-host/internal/buckets" + "github.com/novox/mesh-host/internal/buckets/bucketstest" "strings" "sync/atomic" "testing" @@ -13,7 +17,6 @@ import ( nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/compose" @@ -26,48 +29,48 @@ import ( // within the bound; a shadow not ready and an ended bound answer unready at once, which the checker takes as no answer. func TestTheNodeComposesACandidateOnAsk(t *testing.T) { b := newFakeBus() - fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) - fpNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) - digest := delivery.ManifestDigest([]byte(lemurs)) + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) + fpNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) + digest := meshcatalogclient.ManifestDigest([]byte(lemurs)) lemurs2 := strings.Replace(lemurs, `"version":"1"`, `"version":"2"`, 1) - digest2 := delivery.ManifestDigest([]byte(lemurs2)) + digest2 := meshcatalogclient.ManifestDigest([]byte(lemurs2)) for d, body := range map[string]string{digest: lemurs, digest2: lemurs2} { - man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: body}) - key, _ := delivery.ManifestsKey(d) - b.kv[delivery.ManifestsBucket+"/"+key] = man + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: body}) + key, _ := meshcatalogclient.ManifestsKey(d) + b.kv[buckets.Manifests+"/"+key] = man } - // The indexer keys a candidate by its group alone (delivery.CandidatesKey, the branch name escaped, a slash + // The indexer keys a candidate by its group alone (buckets.CandidateKey, the branch name escaped, a slash // allowed), its heads hash inside the value; the checker asks by the composition key, `.`. candidateOf := func(group, manifest string) string { - head := delivery.Head{Repository: "novox/x", Number: 7, Head: "c0ffee" + manifest[len(manifest)-6:]} - heads, _ := delivery.HeadsHash([]delivery.Head{head}) - pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"lemurs"}, - Fps: map[string]delivery.CandidateModule{"lemurs": {FP2: fpNew, Manifest: manifest}}}} - raw, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: heads, Pulls: pulls, - FPSet: delivery.FingerprintSet(pulls)}) + head := meshdeliveryclient.Head{Repository: "novox/x", Number: 7, Head: "c0ffee" + manifest[len(manifest)-6:]} + heads, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{head}) + pulls := []meshcatalog.CandidatesByGroupValuePullsItem{bucketstest.Pull(head, []string{"lemurs"}, + map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"lemurs": {Fp2: fpNew, Manifest: manifest}})} + raw, err := bucketstest.GroupCandidate(meshcatalog.CandidatesByGroupValue{Group: group, Heads: heads, Pulls: pulls, + Fpset: meshcatalogclient.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } - ckey, _ := delivery.CandidatesKey(group) - b.kv[delivery.CandidatesBucket+"/"+ckey] = raw - key, _ := delivery.CompositionKey(group, heads) + ckey, _ := buckets.CandidateKey(group) + b.kv[buckets.Candidates+"/"+ckey] = raw + key, _ := nec.CompositionKey(group, heads) return key } accepted, refusedKey := candidateOf("fix/940-no-retire-on-partial-read", digest2), "" { // The same change, its manifest refused by the indexer's judge. refusedMan := strings.Replace(lemurs2, `"version":"2"`, `"version":"3"`, 1) - d3 := delivery.ManifestDigest([]byte(refusedMan)) - man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: refusedMan}) - key, _ := delivery.ManifestsKey(d3) - b.kv[delivery.ManifestsBucket+"/"+key] = man - vkey, _ := delivery.VerdictsKey(d3) - v, err := delivery.EncodeManifestVerdict(delivery.ManifestVerdict{Module: "lemurs", State: delivery.VerdictRefused, - Problems: []string{"a field the judge refuses"}, Judge: "src2:j", At: time.Now()}) + d3 := meshcatalogclient.ManifestDigest([]byte(refusedMan)) + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: refusedMan}) + key, _ := meshcatalogclient.ManifestsKey(d3) + b.kv[buckets.Manifests+"/"+key] = man + vkey, _ := meshcatalogclient.VerdictsKey(d3) + v, err := meshcatalogclient.EncodeVerdict(meshcatalog.VerdictsByDigestValue{Module: "lemurs", State: meshcatalogclient.VerdictRefused, + Problems: []string{"a field the judge refuses"}, Judge: "src2:j", At: func(t time.Time) *time.Time { return &t }(time.Now())}) if err != nil { t.Fatal(err) } - b.kv[delivery.VerdictsBucket+"/"+vkey] = v + b.kv[buckets.Verdicts+"/"+vkey] = v refusedKey = candidateOf("fix/941-refused", d3) } @@ -87,7 +90,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) have, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) deadline := time.Now().Add(5 * time.Second) watching := func() int { b.mu.Lock(); defer b.mu.Unlock(); return len(b.watches) } for watching() < 6 && time.Now().Before(deadline) { @@ -95,8 +98,8 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { } b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) - b.put(t, delivery.ModulesBucket, "lemurs", mod) - for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket} { + b.put(t, buckets.Modules, "lemurs", mod) + for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket} { b.send(bucket, agent.Update{Caught: true}) } for !s.ready.Load() && time.Now().Before(deadline) { @@ -129,13 +132,13 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { if _, err := nec.EncodeAnswer(r); err != nil { t.Errorf("the refused answer does not fit the seat's shape: %v", err) } - missing, _ := delivery.CompositionKey("novox/x#8", "0123456789ab") + missing, _ := nec.CompositionKey("novox/x#8", "0123456789ab") if m := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: missing}); !strings.Contains(m.Refused, "holds no candidate") { t.Errorf("a candidate the indexer does not hold answered %+v", m) } // The group's candidate moved to other heads since the ask: the node composes not the one asked, and answers // unready (no answer), naming the heads it holds. - moved, _ := delivery.CompositionKey("fix/940-no-retire-on-partial-read", "0123456789ab") + moved, _ := nec.CompositionKey("fix/940-no-retire-on-partial-read", "0123456789ab") if m := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: moved}); m.Verdict != nec.Unready || !strings.Contains(m.Refused, "heads") { t.Errorf("a candidate at other heads answered %+v", m) @@ -154,30 +157,30 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { // composition, into the shadow, and a later ask reads none; an ask whose read does not answer is answered unready at // its bound, naming what the node waits on; and every answer is said in one line with what it waited on. t.Run("each manifest is read once, and the answer comes within its bound", func(t *testing.T) { - fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) - fpNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) + fpNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) lemurs2 := strings.Replace(lemurs, `"version":"1"`, `"version":"2"`, 1) other := strings.NewReplacer(`"module":"lemurs"`, `"module":"other"`, "lemurs.service", "other.service").Replace(lemurs) - digest, digest2, digestOther := delivery.ManifestDigest([]byte(lemurs)), delivery.ManifestDigest([]byte(lemurs2)), - delivery.ManifestDigest([]byte(other)) - otherKey, _ := delivery.ManifestsKey(digestOther) + digest, digest2, digestOther := meshcatalogclient.ManifestDigest([]byte(lemurs)), meshcatalogclient.ManifestDigest([]byte(lemurs2)), + meshcatalogclient.ManifestDigest([]byte(other)) + otherKey, _ := meshcatalogclient.ManifestsKey(digestOther) // shadow is a ready shadow of n1 over b: lemurs placed on it, other placed nowhere (its manifest read for the mesh's // catalogue alone), and a candidate laying lemurs at a new build; its clock moves a second at each reading. shadow := func(b *fakeBus) (*Shadow, string) { for d, body := range map[string]string{digest: lemurs, digest2: lemurs2, digestOther: other} { - man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "x", JSON: body}) - key, _ := delivery.ManifestsKey(d) - b.kv[delivery.ManifestsBucket+"/"+key] = man + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "x", Manifest: body}) + key, _ := meshcatalogclient.ManifestsKey(d) + b.kv[buckets.Manifests+"/"+key] = man } - head := delivery.Head{Repository: "novox/x", Number: 7, Head: "c0ffee"} - heads, _ := delivery.HeadsHash([]delivery.Head{head}) - pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"lemurs"}, - Fps: map[string]delivery.CandidateModule{"lemurs": {FP2: fpNew, Manifest: digest2}}}} - raw, _ := delivery.EncodeCandidate(delivery.Candidate{Group: "fix/1126", HeadsHash: heads, Pulls: pulls, - FPSet: delivery.FingerprintSet(pulls)}) - ckey, _ := delivery.CandidatesKey("fix/1126") - b.kv[delivery.CandidatesBucket+"/"+ckey] = raw + head := meshdeliveryclient.Head{Repository: "novox/x", Number: 7, Head: "c0ffee"} + heads, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{head}) + pulls := []meshcatalog.CandidatesByGroupValuePullsItem{bucketstest.Pull(head, []string{"lemurs"}, + map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"lemurs": {Fp2: fpNew, Manifest: digest2}})} + raw, _ := bucketstest.GroupCandidate(meshcatalog.CandidatesByGroupValue{Group: "fix/1126", Heads: heads, Pulls: pulls, + Fpset: meshcatalogclient.FingerprintSet(pulls)}) + ckey, _ := buckets.CandidateKey("fix/1126") + b.kv[buckets.Candidates+"/"+ckey] = raw env := b.env() var tick atomic.Int64 env.Now = func() time.Time { return time.Unix(0, 0).Add(time.Duration(tick.Add(1)) * time.Second) } @@ -185,17 +188,17 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { s.c.placements["n1"] = meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} s.c.records["n1"] = nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}} - s.c.modules["lemurs"] = delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"} - s.c.modules["other"] = delivery.Module{FP2: fp, Manifest: digestOther, Repository: "novox/x", Commit: "c"} + s.c.modules["lemurs"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"} + s.c.modules["other"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digestOther, Repository: "novox/x", Commit: "c"} s.ready.Store(true) - key, _ := delivery.CompositionKey("fix/1126", heads) + key, _ := nec.CompositionKey("fix/1126", heads) return s, key } readsOf := func(b *fakeBus, key string) (n int) { b.mu.Lock() defer b.mu.Unlock() for _, r := range b.reads { - if r == [2]string{delivery.ManifestsBucket, key} { + if r == [2]string{buckets.Manifests, key} { n++ } } @@ -235,7 +238,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { if tc.stuck { get := s.c.env.Get s.c.env.Get = func(rctx context.Context, bucket, k string) ([]byte, error) { - if bucket == delivery.ManifestsBucket && k == otherKey { + if bucket == buckets.Manifests && k == otherKey { b.mu.Lock() b.reads = append(b.reads, [2]string{bucket, k}) b.mu.Unlock() diff --git a/internal/selfcompose/composedstate.go b/internal/selfcompose/composedstate.go index af2c1f4..5693f90 100644 --- a/internal/selfcompose/composedstate.go +++ b/internal/selfcompose/composedstate.go @@ -90,7 +90,7 @@ func (c *Composer) newestFPs(ctx context.Context, at *published) map[string]stri r := reader{c: c} for _, module := range at.placed { c.mu.Lock() - fp2 := c.modules[module].FP2 + fp2 := c.modules[module].Fp2 c.mu.Unlock() if fp2 != "" { if b, err := r.Build(ctx, module, fp2); err == nil && b != nil && b.State == delivery.BuildDone { @@ -184,7 +184,7 @@ func (c *Composer) composedValues(ctx context.Context, at *published) (map[strin var inOwn []string c.mu.Lock() for module, fp := range newest { - if fp != c.modules[module].FP2 { + if fp != c.modules[module].Fp2 { scope.KeptAt[module] = fp } } diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index d971e0e..4cf6e82 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -7,6 +7,8 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -70,15 +72,15 @@ func (f *fakeComposed) written() []string { // by a want, and deleted otherwise. func TestTheNodePublishesItsCompositions(t *testing.T) { b := newFakeBus() - fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) - ofp := delivery.SourcePrefix2 + strings.Repeat("c", 64) - digest := delivery.ManifestDigest([]byte(lemurs)) + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) + ofp := meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64) + digest := meshcatalogclient.ManifestDigest([]byte(lemurs)) oldie := `{"module":"oldie","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"oldie.service","state":"running","boot":"enabled"}]}` - odigest := delivery.ManifestDigest([]byte(oldie)) + odigest := meshcatalogclient.ManifestDigest([]byte(oldie)) for d, json := range map[string]string{digest: lemurs, odigest: oldie} { - man, _ := delivery.EncodeManifest(delivery.Manifest{Module: map[string]string{digest: "lemurs", odigest: "oldie"}[d], JSON: json}) - key, _ := delivery.ManifestsKey(d) - b.kv[delivery.ManifestsBucket+"/"+key] = man + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: map[string]string{digest: "lemurs", odigest: "oldie"}[d], Manifest: json}) + key, _ := meshcatalogclient.ManifestsKey(d) + b.kv[buckets.Manifests+"/"+key] = man } state := &fakeComposed{kv: map[string]ComposedEntry{}} env := b.env() @@ -93,17 +95,17 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) - omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + omod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) - b.put(t, delivery.ModulesBucket, "lemurs", mod) - b.put(t, delivery.ModulesBucket, "oldie", omod) + b.put(t, buckets.Modules, "lemurs", mod) + b.put(t, buckets.Modules, "oldie", omod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } for u := next(t, out); !u.Caught; u = next(t, out) { @@ -166,7 +168,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { keep.pubKept = map[string]ComposedEntry{} var keys []string for i, ch := range "123456" { - key, _ := engcomp.Key("n1", "lemurs", delivery.SourcePrefix2+strings.Repeat(string(ch), 64)) + key, _ := engcomp.Key("n1", "lemurs", meshcatalogclient.SourcePrefix2+strings.Repeat(string(ch), 64)) keys = append(keys, key) keep.pubKept[key] = ComposedEntry{Revision: uint64(10 + i)} } @@ -174,7 +176,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { if past := keep.pastKeeping(); !slices.Equal(past, []string{keys[0]}) { t.Errorf("past keeping: %v, want the oldest of six, %s", past, keys[0]) } - keep.wants = map[string]meshwants.WantsByWantNodeModuleValue{"lemurs": {Fp: delivery.SourcePrefix2 + strings.Repeat("1", 64)}} + keep.wants = map[string]meshwants.WantsByWantNodeModuleValue{"lemurs": {Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("1", 64)}} if past := keep.pastKeeping(); len(past) != 0 { t.Errorf("past keeping with a want naming the oldest: %v, want none", past) } diff --git a/internal/selfcompose/data_test.go b/internal/selfcompose/data_test.go index dc32133..d2273d3 100644 --- a/internal/selfcompose/data_test.go +++ b/internal/selfcompose/data_test.go @@ -5,6 +5,7 @@ import ( "errors" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "slices" @@ -93,7 +94,7 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { t.Error("the node's grant reads other keys of the engine's state data than its own") } c := New(env) - webKey, _ := engcomp.Key("n1", "web", delivery.SourcePrefix2+strings.Repeat("a", 64)) + webKey, _ := engcomp.Key("n1", "web", meshcatalogclient.SourcePrefix2+strings.Repeat("a", 64)) nodeKey, _ := engcomp.NodeKey("n1") for _, step := range []struct { name string @@ -234,9 +235,9 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { // The items are worked out from what the pass read: a done build is read over the bus once, not again for the data // items after the pass (a module in a running build is read again, since it may land). b := newFakeBus() - done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest([]byte(lemurs))}) + done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest([]byte(lemurs))}) running, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: "building"}) - fp := delivery.SourcePrefix2 + strings.Repeat("b", 64) + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) doneKey, _ := delivery.BuilderBuildKey("web", fp) runningKey, _ := delivery.BuilderBuildKey("pg", fp) b.kv[delivery.BuilderBuildsBucket+"/"+doneKey], b.kv[delivery.BuilderBuildsBucket+"/"+runningKey] = done, running @@ -257,12 +258,12 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { t.Errorf("the builds were read %v times over the bus; want the done one once, the running one each time", reads) } // A done key the builder claims again (a gone step, issue 1937) and lands done again is read again, not served kept. - again, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest([]byte("{}"))}) + again, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest([]byte("{}"))}) b.mu.Lock() b.kv[delivery.BuilderBuildsBucket+"/"+doneKey] = again b.mu.Unlock() r.c.takeBuildDone(agent.Update{Key: doneKey, Value: running}) - if got, err := r.Build(context.Background(), "web", fp); err != nil || got.Manifest != delivery.ManifestDigest([]byte("{}")) { + if got, err := r.Build(context.Background(), "web", fp); err != nil || got.Manifest != meshcatalogclient.ManifestDigest([]byte("{}")) { t.Errorf("a done build written again is served as %+v (%v), not read again", got, err) } } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 3f39ef9..ce02dfd 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -24,6 +24,8 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -141,7 +143,7 @@ type Composer struct { // inOwn is, by module, whether this node's have of it names no fingerprint of the new form (mesh-wireguard, a module // at a build of the old form): the node runs it inside its own composition, and composes it there again (issue 1126). inOwn map[string]bool - modules map[string]delivery.Module + modules map[string]meshcatalog.ModulesByModuleValue told map[string]bool // lines said once each (sayOnceEach) values map[string]map[string]any // by module, this node's manifests map[string][]byte // by digest, read once @@ -304,7 +306,7 @@ func New(env Env) *Composer { env.Say = func(string) {} } return &Composer{env: env, wants: map[string]meshwants.WantsByWantNodeModuleValue{}, placements: map[string]meshmoduleassigner.Placement{}, placementKeys: meshmoduleassignerclient.NewKeys(), - records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{}, + records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]meshcatalog.ModulesByModuleValue{}, values: map[string]map[string]any{}, manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]delivery.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} } @@ -365,7 +367,7 @@ func (c *Composer) inputs() []input { // A watch of every key is refused by the bus (issue 1126, 2026-10-27 09:21: the fold's `>` stopped every agent). {buckets.Have, "*." + nodeengineclient.NodeWord, nil, (*Composer).takeRecordChanged}, {buckets.Have, node + ".>", nil, (*Composer).takeModuleHave}, - {delivery.ModulesBucket, ">", (*Composer).takeModule, nil}, + {buckets.Modules, ">", (*Composer).takeModule, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, // The builder's builds: a pass that held a module for a build not yet done is made again when it lands (issue // 774, 2026-10-30 19:30: node-tools' build landed and ace, g14 and shanks stayed refused until another input moved). @@ -398,7 +400,7 @@ func (c *Composer) takeBuildDone(u agent.Update) bool { c.mu.Unlock() // A module placed here is composed at its trunk's build once that is done, for the composition state, before its // want moves (ADR 0593 §6). - if !(wanted && w.Fp == fp) && !((wanted || placedHere) && m.FP2 == fp) { + if !(wanted && w.Fp == fp) && !((wanted || placedHere) && m.Fp2 == fp) { return false } b, err := delivery.DecodeBuilderBuild(u.Value) @@ -628,8 +630,8 @@ func (c *Composer) entry(bucket, key string) (v any, has, known bool) { } p, ok := c.placements[node] return p, ok, true - case delivery.ModulesBucket: - name, err := delivery.ParseModulesKey(key) + case buckets.Modules: + name, err := meshcatalogclient.ParseModulesKey(key) if err != nil { return nil, false, true } @@ -737,7 +739,7 @@ func (c *Composer) takeModuleHave(u agent.Update) bool { // (waiting for a yes inside the node's own stays in). interrupted := h.Phase == nodeengineclient.PhaseHeld && h.Detail == agent.BackupInterrupted runsNothing := (h.Phase == nodeengineclient.PhaseFailed || interrupted) && h.Fp == "" && h.Composed == "" - in = h.Phase != nodeengineclient.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.Fp, delivery.SourcePrefix2) + in = h.Phase != nodeengineclient.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.Fp, meshcatalogclient.SourcePrefix2) } } c.mu.Lock() @@ -784,13 +786,13 @@ func (c *Composer) inOwnOf(p meshmoduleassigner.Placement) []string { } func (c *Composer) takeModule(u agent.Update) { - name, err := delivery.ParseModulesKey(u.Key) + name, err := meshcatalogclient.ParseModulesKey(u.Key) if err != nil { return } c.mu.Lock() defer c.mu.Unlock() - m, err := delivery.DecodeModule(u.Value) + m, err := meshcatalogclient.DecodeModule(u.Value) if u.Deleted || err != nil { delete(c.modules, name) return @@ -863,7 +865,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { continue } fps[module] = w.Fp - if m, ok := c.modules[module]; ok && m.FP2 != w.Fp { + if m, ok := c.modules[module]; ok && m.Fp2 != w.Fp { behind = append(behind, module) } } @@ -1263,10 +1265,10 @@ func (c *Composer) HoldsData(ctx context.Context, module string) (data, known bo // the indexer's verdict from the module's value (a missing one is accepted). held is why the module is held, "" when it is // not: a manifest the indexer refused, or one not read past a field this engine does not know. A field set aside is said // once, and an error is returned only for bytes that are no manifest, which the caller says in its own words. -func (c *Composer) taken(module string, m delivery.Module, raw []byte) (man catalogue.Manifest, held string, err error) { - if v := m.VerdictOf(module); v != nil { - if refused, _ := delivery.TakeManifestVerdict(v); refused { - return catalogue.Manifest{}, v.Why(), nil +func (c *Composer) taken(module string, m meshcatalog.ModulesByModuleValue, raw []byte) (man catalogue.Manifest, held string, err error) { + if v := meshcatalogclient.ModuleVerdict(module, m); v != nil { + if refused, _ := meshcatalogclient.TakeVerdict(v); refused { + return catalogue.Manifest{}, meshcatalogclient.VerdictWhy(*v), nil } } man, err = catalogue.Decode(raw) @@ -1390,10 +1392,10 @@ func (r reader) Records(context.Context) (map[string]nodeengine.NodeRecord, erro return out, nil } -func (r reader) Modules(context.Context) (map[string]delivery.Module, error) { +func (r reader) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) { r.c.mu.Lock() defer r.c.mu.Unlock() - out := make(map[string]delivery.Module, len(r.c.modules)) + out := make(map[string]meshcatalog.ModulesByModuleValue, len(r.c.modules)) for k, v := range r.c.modules { out[k] = v } @@ -1407,24 +1409,24 @@ func (r reader) Manifest(ctx context.Context, digest string) ([]byte, error) { if ok { return raw, nil } - key, err := delivery.ManifestsKey(digest) + key, err := meshcatalogclient.ManifestsKey(digest) if err != nil { return nil, err } - defer r.c.waiting("the manifest " + digest + " (" + delivery.ManifestsBucket + ")")() - value, err := r.c.get(ctx, delivery.ManifestsBucket, key) + defer r.c.waiting("the manifest " + digest + " (" + buckets.Manifests + ")")() + value, err := r.c.get(ctx, buckets.Manifests, key) if err != nil { return nil, err } if value == nil { return nil, fmt.Errorf("the indexer holds no manifest %s", digest) } - m, err := delivery.DecodeManifest(value) + m, err := meshcatalogclient.DecodeManifest(value) if err != nil { return nil, err } - r.c.keepRead(func(k *Composer) { k.manifests[digest] = []byte(m.JSON) }) - return []byte(m.JSON), nil + r.c.keepRead(func(k *Composer) { k.manifests[digest] = []byte(m.Manifest) }) + return []byte(m.Manifest), nil } func (r reader) Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { @@ -1723,13 +1725,13 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string continue } fps[m] = w.Fp - if mm, ok := c.modules[m]; ok && mm.FP2 != w.Fp { + if mm, ok := c.modules[m]; ok && mm.Fp2 != w.Fp { kept[m] = w.Fp } } fps[module] = fp delete(kept, module) - if mm, ok := c.modules[module]; !ok || mm.FP2 != fp { + if mm, ok := c.modules[module]; !ok || mm.Fp2 != fp { kept[module] = fp } own, placed := c.placements[c.env.Node] diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index d029b67..dcbaa2c 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -13,6 +13,8 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" @@ -151,11 +153,11 @@ func quiet(t *testing.T, out <-chan agent.Update) { // names any more is taken back; a node that cannot compose says so once and hands nothing. func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b := newFakeBus() - fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) - digest := delivery.ManifestDigest([]byte(lemurs)) - man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: lemurs}) - key, _ := delivery.ManifestsKey(digest) - b.kv[delivery.ManifestsBucket+"/"+key] = man + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) + digest := meshcatalogclient.ManifestDigest([]byte(lemurs)) + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs}) + key, _ := meshcatalogclient.ManifestsKey(digest) + b.kv[buckets.Manifests+"/"+key] = man ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -188,26 +190,26 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // oldie is placed and no want names it (a module at a build of the old form, nats on novox): the node runs it inside // its own composition, and composes it there again, even before the agent has applied anything (Running empty). oldie := `{"module":"oldie","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"oldie.service","state":"running","boot":"enabled"}]}` - odigest := delivery.ManifestDigest([]byte(oldie)) - oman, _ := delivery.EncodeManifest(delivery.Manifest{Module: "oldie", JSON: oldie}) - okey, _ := delivery.ManifestsKey(odigest) - b.kv[delivery.ManifestsBucket+"/"+okey] = oman - omod, _ := delivery.EncodeModule(delivery.Module{FP2: delivery.SourcePrefix2 + strings.Repeat("c", 64), Manifest: odigest, Repository: "novox/o", Commit: "c"}) + odigest := meshcatalogclient.ManifestDigest([]byte(oldie)) + oman, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "oldie", Manifest: oldie}) + okey, _ := meshcatalogclient.ManifestsKey(odigest) + b.kv[buckets.Manifests+"/"+okey] = oman + omod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), Manifest: odigest, Repository: "novox/o", Commit: "c"}) place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) oldHave, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) b.put(t, buckets.Have, "n1.oldie", oldHave) - b.put(t, delivery.ModulesBucket, "lemurs", mod) - b.put(t, delivery.ModulesBucket, "oldie", omod) + b.put(t, buckets.Modules, "lemurs", mod) + b.put(t, buckets.Modules, "oldie", omod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } got := map[string]bool{} @@ -285,17 +287,17 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if dig, ok := c.ComposedToday(ctx, "lemurs", fp); !ok || dig != handedDig { t.Errorf("composed today at the build it runs: %s %v, want %s", dig, ok, handedDig) } - if dig, ok := c.ComposedToday(ctx, "lemurs", delivery.SourcePrefix2+strings.Repeat("9", 64)); ok { + if dig, ok := c.ComposedToday(ctx, "lemurs", meshcatalogclient.SourcePrefix2+strings.Repeat("9", 64)); ok { t.Errorf("a build that cannot be composed was composed today: %s", dig) } chgJSON := strings.Replace(lemurs, `"state":"running"`, `"state":"stopped"`, 1) - chgDigest := delivery.ManifestDigest([]byte(chgJSON)) - chgMan, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: chgJSON}) - chgKey, _ := delivery.ManifestsKey(chgDigest) + chgDigest := meshcatalogclient.ManifestDigest([]byte(chgJSON)) + chgMan, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: chgJSON}) + chgKey, _ := meshcatalogclient.ManifestsKey(chgDigest) b.mu.Lock() - b.kv[delivery.ManifestsBucket+"/"+chgKey] = chgMan + b.kv[buckets.Manifests+"/"+chgKey] = chgMan b.mu.Unlock() - chgMod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: chgDigest, Repository: "novox/x", Commit: "c"}) + chgMod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: chgDigest, Repository: "novox/x", Commit: "c"}) c.takeModule(agent.Update{Key: "lemurs", Value: chgMod}) if dig, ok := c.ComposedToday(ctx, "lemurs", fp); !ok || dig == handedDig { t.Errorf("composed today with a changed input: %s %v, want another digest than %s", dig, ok, handedDig) @@ -427,14 +429,14 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { `{"id":"pkg","type":"package","package":"haproxy"}]}` c3, told := fresh(env.Get) for m, raw := range map[string]string{"de-spiegel": string(spiegel), "proxy": proxy} { - dig := delivery.ManifestDigest([]byte(raw)) - enc, _ := delivery.EncodeManifest(delivery.Manifest{Module: m, JSON: raw}) - k, _ := delivery.ManifestsKey(dig) + dig := meshcatalogclient.ManifestDigest([]byte(raw)) + enc, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: m, Manifest: raw}) + k, _ := meshcatalogclient.ManifestsKey(dig) b.mu.Lock() - b.kv[delivery.ManifestsBucket+"/"+k] = enc + b.kv[buckets.Manifests+"/"+k] = enc b.mu.Unlock() - mfp := delivery.SourcePrefix2 + strings.Repeat(map[string]string{"de-spiegel": "d", "proxy": "e"}[m], 64) - c3.modules[m] = delivery.Module{FP2: mfp, Manifest: dig, Repository: "novox/" + m, Commit: "c"} + mfp := meshcatalogclient.SourcePrefix2 + strings.Repeat(map[string]string{"de-spiegel": "d", "proxy": "e"}[m], 64) + c3.modules[m] = meshcatalog.ModulesByModuleValue{Fp2: mfp, Manifest: dig, Repository: "novox/" + m, Commit: "c"} c3.wants[m] = meshwants.WantsByWantNodeModuleValue{Fp: mfp, Self: true} } p3 := c3.placements["n1"] @@ -480,16 +482,16 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Fatal(err) } c4, told4 := fresh(env.Get) - powerDig := delivery.ManifestDigest(power) - powerEnc, _ := delivery.EncodeManifest(delivery.Manifest{Module: "power", JSON: string(power)}) - powerKey, _ := delivery.ManifestsKey(powerDig) + powerDig := meshcatalogclient.ManifestDigest(power) + powerEnc, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "power", Manifest: string(power)}) + powerKey, _ := meshcatalogclient.ManifestsKey(powerDig) b.mu.Lock() - b.kv[delivery.ManifestsBucket+"/"+powerKey] = powerEnc + b.kv[buckets.Manifests+"/"+powerKey] = powerEnc b.mu.Unlock() - powerFP := delivery.SourcePrefix2 + strings.Repeat("c", 64) - c4.modules["power"] = delivery.Module{FP2: powerFP, Manifest: powerDig, Repository: "novox/mesh-catalog", Commit: "ca8d6f33", + powerFP := meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64) + c4.modules["power"] = meshcatalog.ModulesByModuleValue{Fp2: powerFP, Manifest: powerDig, Repository: "novox/mesh-catalog", Commit: "ca8d6f33", // The indexer's verdict on the captured manifest (ADR 0578): the engine judges nothing, it takes this. - Verdict: delivery.VerdictRefused, Problems: []string{`bar item "shows" is none of them`}} + Verdict: meshcatalogclient.VerdictRefused, Problems: []string{`bar item "shows" is none of them`}} c4.wants["power"] = meshwants.WantsByWantNodeModuleValue{Fp: powerFP, Self: true} p4 := c4.placements["n1"] p4.Modules = append(append([]meshmoduleassigner.PlacedModule{}, p4.Modules...), meshmoduleassigner.PlacedModule{Module: "power"}) @@ -505,7 +507,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Errorf("the agent was told %q, want power held alone, naming its item", *told4) } // Review of issue 774 (the kept read): mesh-wireguard, which the mesh provides and which never has a fingerprint, is - // folded in the node's own and composed from the manifest mesh-sdk provides (delivery.ProvidedManifest), not from + // folded in the node's own and composed from the manifest mesh-sdk provides (overlay.ProvidedManifest), not from // what runs nor the kept declaration: with nothing running (the kept read retired, Running empty) the node's own // still carries its resources, so retiring the read removes nothing of it. c5, told5 := fresh(env.Get) @@ -569,9 +571,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { }{ {"lemurs's wanted build, not done", "lemurs", fp, "building", false}, {"lemurs's wanted build lands", "lemurs", fp, delivery.BuildDone, true}, - {"another build of lemurs", "lemurs", delivery.SourcePrefix2 + strings.Repeat("e", 64), delivery.BuildDone, false}, - {"a placed module no want names, at its trunk's build", "oldie", delivery.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, true}, - {"a module neither wanted nor placed here", "ghost", delivery.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, false}, + {"another build of lemurs", "lemurs", meshcatalogclient.SourcePrefix2 + strings.Repeat("e", 64), delivery.BuildDone, false}, + {"a placed module no want names, at its trunk's build", "oldie", meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, true}, + {"a module neither wanted nor placed here", "ghost", meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, false}, } { before := passes() key, _ := delivery.BuilderBuildKey(row.module, row.fp) @@ -592,7 +594,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Issue 774, 2026-10-31: every put under want.n1.> made a pass, and each pass asked the vault for the node's own // secrets. A key written again with what it held, or a want whose words moved and not its build, makes none. worded, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true, Words: "lemurs is held at aaaaaaaaaaaa"}) - elsewhere, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("e", 64), Self: true}) + elsewhere, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("e", 64), Self: true}) // Research 072 row 37: the placement converted to one key per module and node moves nothing composed; a write in // keys makes its pass at its node key, the commit, and not at a module key written before it nor at the mirror. placeBase := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, @@ -645,7 +647,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { {"the node's own want again", buckets.Wants, "want.n1.node", nodeWant, false}, {"lemurs's want with other words", buckets.Wants, "want.n1.lemurs", worded, false}, {"the placement again", buckets.Placement, "placement.n1", place, false}, - {"lemurs's module again", delivery.ModulesBucket, "lemurs", mod, false}, + {"lemurs's module again", buckets.Modules, "lemurs", mod, false}, {"lemurs's want at another build", buckets.Wants, "want.n1.lemurs", elsewhere, true}, {"lemurs's want back at its build", buckets.Wants, "want.n1.lemurs", want, true}, }, placementRows...) { @@ -672,11 +674,11 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { tb := newFakeBus() tool := `{"module":"tool","version":"1","capabilities":["systemd"],"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + `"resources":[{"id":"service","type":"service","unit":"tool.service","state":"running","boot":"enabled"}]}` - tdigest := delivery.ManifestDigest([]byte(tool)) - tman, _ := delivery.EncodeManifest(delivery.Manifest{Module: "tool", JSON: tool}) - tkey, _ := delivery.ManifestsKey(tdigest) - tb.kv[delivery.ManifestsBucket+"/"+tkey] = tman - old, next2 := delivery.SourcePrefix2+strings.Repeat("1", 64), delivery.SourcePrefix2+strings.Repeat("2", 64) + tdigest := meshcatalogclient.ManifestDigest([]byte(tool)) + tman, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "tool", Manifest: tool}) + tkey, _ := meshcatalogclient.ManifestsKey(tdigest) + tb.kv[buckets.Manifests+"/"+tkey] = tman + old, next2 := meshcatalogclient.SourcePrefix2+strings.Repeat("1", 64), meshcatalogclient.SourcePrefix2+strings.Repeat("2", 64) tenv := tb.env() var held []string var hmu sync.Mutex @@ -696,16 +698,16 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } tplace, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "tool"}}}) - tmod, _ := delivery.EncodeModule(delivery.Module{FP2: next2, Manifest: tdigest, Repository: "novox/t", Commit: "c"}) + tmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: next2, Manifest: tdigest, Repository: "novox/t", Commit: "c"}) twant, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: next2, Self: true}) building, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: "building"}) bkey, _ := delivery.BuilderBuildKey("tool", next2) tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = building tb.put(t, buckets.Placement, "placement.n1", tplace) tb.put(t, buckets.Have, "n1.node", have) - tb.put(t, delivery.ModulesBucket, "tool", tmod) + tb.put(t, buckets.Modules, "tool", tmod) tb.put(t, buckets.Wants, "want.n1.tool", twant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, delivery.BuilderBuildsBucket} { tb.send(bucket, agent.Update{Caught: true}) } @@ -740,7 +742,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } // A want of a module whose manifest cannot be read: said once, nothing handed, what was handed stands. - gone := delivery.SourcePrefix2 + strings.Repeat("b", 64) + gone := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) missing, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: gone, Self: true}) b.put(t, buckets.Wants, "want.n1.lemurs", missing) quiet(t, out) @@ -766,16 +768,16 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } keeper := `{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` + `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}` - kdigest := delivery.ManifestDigest([]byte(keeper)) - kman, _ := delivery.EncodeManifest(delivery.Manifest{Module: "keeper", JSON: keeper}) - kkey, _ := delivery.ManifestsKey(kdigest) + kdigest := meshcatalogclient.ManifestDigest([]byte(keeper)) + kman, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "keeper", Manifest: keeper}) + kkey, _ := meshcatalogclient.ManifestsKey(kdigest) b.mu.Lock() - b.kv[delivery.ManifestsBucket+"/"+kkey] = kman + b.kv[buckets.Manifests+"/"+kkey] = kman b.mu.Unlock() - kmod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}) + kmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}) both, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}, {Module: "keeper"}}}) - b.put(t, delivery.ModulesBucket, "keeper", kmod) + b.put(t, buckets.Modules, "keeper", kmod) // n1 is held in keys per module and node since the rows above, which a legacy key no longer moves. bothPlaced, _ := meshmoduleassignerclient.DecodePlacement(both) bothWrites, err := placeKeys.Writes("n1", &bothPlaced) @@ -1041,9 +1043,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } sb := newPatternBus() trunk := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} - mods := map[string]delivery.Module{"lemurs": {FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}, - "keeper": {FP2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}} - readOnly := &fakeBus{kv: map[string][]byte{delivery.ManifestsBucket + "/" + key: man, delivery.ManifestsBucket + "/" + kkey: kman}} + mods := map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}, + "keeper": {Fp2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}} + readOnly := &fakeBus{kv: map[string][]byte{buckets.Manifests + "/" + key: man, buckets.Manifests + "/" + kkey: kman}} senv := readOnly.env() senv.Watch = sb.watch var ownFails atomic.Bool // the vault's own on a bus that does not answer (2026-10-28, ace's answer to seed-7) @@ -1096,8 +1098,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { sb.put(buckets.Placement, "placement.n1", tp, 1) sb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { - raw, _ := delivery.EncodeModule(m) - sb.put(delivery.ModulesBucket, name, raw, 1) + raw, _ := meshcatalogclient.EncodeModule(m) + sb.put(buckets.Modules, name, raw, 1) } wantLemurs := meshwants.WantsByWantNodeModuleKey("n1", "lemurs") var lemursDigest string @@ -1252,8 +1254,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { lemursHave("its tool lemurs_health: no answer within 10s", 11) lemursHave("its tool lemurs_health: no answer within 20s", 12) sb.put(buckets.Placement, "placement.n1", tp, 2) - lraw, _ := delivery.EncodeModule(mods["lemurs"]) - sb.put(delivery.ModulesBucket, "lemurs", lraw, 2) + lraw, _ := meshcatalogclient.EncodeModule(mods["lemurs"]) + sb.put(buckets.Modules, "lemurs", lraw, 2) drain() if got := answering(); got != settled { t.Errorf("answered %d more time(s) with no candidate revision and no input of the node changed", got-settled) @@ -1311,7 +1313,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Review of L2b, round 2: a watch ends while the link stays; a group's key is deleted before the shadow watches // again. The new run reads afresh, so that group is no longer answered. sb.missing(buckets.PlacementCandidates, true) - sb.end(delivery.ModulesBucket) + sb.end(buckets.Modules) dropsKey := meshmoduleassigner.CandidatesByProposalNodeKey("drops", "n1") sb.del(buckets.PlacementCandidates, dropsKey) time.Sleep(30 * time.Millisecond) @@ -1358,8 +1360,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { nb.put(buckets.Placement, "placement.n1", tp, 1) nb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { - raw, _ := delivery.EncodeModule(m) - nb.put(delivery.ModulesBucket, name, raw, 1) + raw, _ := meshcatalogclient.EncodeModule(m) + nb.put(buckets.Modules, name, raw, 1) } sameRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "same", Placement: &trunk}) sameKey := meshmoduleassigner.CandidatesByProposalNodeKey("same", "n1") @@ -1368,7 +1370,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { oldRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "older", Placement: &trunk}) oldKey := meshmoduleassigner.CandidatesByProposalNodeKey("older", "n1") nb.put(buckets.PlacementCandidates, oldKey, oldRaw, 2) - for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, delivery.ModulesBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, buckets.Modules, delivery.BuilderBuildsBucket} { nb.send(bucket, agent.Update{Caught: true}) } select { @@ -1451,7 +1453,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { inOwnRunning := []compositions.Composed{{Node: "n1", Module: "lemurs", FP: fp}} other := "src2:" + strings.Repeat("9", 64) gotFps := shadowBuilds(inOwnRunning, meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "nats"}, {Module: "keeper"}}}, - map[string]delivery.Module{"lemurs": {FP2: other}, "nats": {FP2: other}, "keeper": {FP2: other}}, []string{"nats"}) + map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Fp2: other}, "nats": {Fp2: other}, "keeper": {Fp2: other}}, []string{"nats"}) if fmt.Sprint(gotFps) != fmt.Sprint(map[string]string{"lemurs": fp, "keeper": other}) { t.Errorf("shadow builds %v; want lemurs at what runs, keeper at the trunk's, nats at none (folded)", gotFps) } @@ -1510,10 +1512,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { certEnv.VaultAuthority = func() string { return "" } cc := New(certEnv) withCert := `{"module":"web","version":"1","certificate":{"into":"/etc/web/tls.crt"}}` - cdigest := delivery.ManifestDigest([]byte(withCert)) + cdigest := meshcatalogclient.ManifestDigest([]byte(withCert)) cc.manifests[digest] = []byte(lemurs) cc.manifests[cdigest] = []byte(withCert) - cc.modules = map[string]delivery.Module{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}} + cc.modules = map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}} cc.records["n1"] = nodeengine.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))} onlyLemurs := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs) @@ -1663,7 +1665,7 @@ func (b *patternBus) caught() { type fixedReader struct { placements map[string]meshmoduleassigner.Placement records map[string]nodeengine.NodeRecord - modules map[string]delivery.Module + modules map[string]meshcatalog.ModulesByModuleValue manifests map[string][]byte } @@ -1673,7 +1675,7 @@ func (r fixedReader) Placements(context.Context) (map[string]meshmoduleassigner. func (r fixedReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { return r.records, nil } -func (r fixedReader) Modules(context.Context) (map[string]delivery.Module, error) { +func (r fixedReader) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) { return r.modules, nil } func (r fixedReader) Manifest(_ context.Context, d string) ([]byte, error) { @@ -1717,7 +1719,7 @@ func TestAHaveSaysWhetherItsModuleRunsInsideTheNodesOwn(t *testing.T) { Health: nodeengineclient.HealthFailed, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, true, true}, // The live have of novox's de-spiegel (2026-10-28): a build of the old form, named by `build`, and no `fp`. {"running a build of the old form", nodeengine.HaveByNodeModuleValue{Build: "build-1790368817085312877", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, true, true}, - {"running a build of the new form", nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64), + {"running a build of the new form", nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, false, false}, {"running with no fingerprint (mesh-wireguard)", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, true, false}, {"held today", nodeengine.HaveByNodeModuleValue{Fp: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseHeld, @@ -1768,11 +1770,11 @@ func (g *gate) MayAct(...string) bool { return g.open.Load() } // already runs with the same composition is not held. func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { b := newFakeBus() - fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) - digest := delivery.ManifestDigest([]byte(lemurs)) - man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: lemurs}) - key, _ := delivery.ManifestsKey(digest) - b.kv[delivery.ManifestsBucket+"/"+key] = man + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) + digest := meshcatalogclient.ManifestDigest([]byte(lemurs)) + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs}) + key, _ := meshcatalogclient.ManifestsKey(digest) + b.kv[buckets.Manifests+"/"+key] = man ctx, cancel := context.WithCancel(context.Background()) defer cancel() env := b.env() @@ -1790,15 +1792,15 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) b.put(t, buckets.Placement, "placement.n1", place) b.put(t, buckets.Have, "n1.node", have) - b.put(t, delivery.ModulesBucket, "lemurs", mod) + b.put(t, buckets.Modules, "lemurs", mod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, delivery.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } handed := map[string]bool{} diff --git a/internal/selfcompose/shadow.go b/internal/selfcompose/shadow.go index 4ca4646..abc73a1 100644 --- a/internal/selfcompose/shadow.go +++ b/internal/selfcompose/shadow.go @@ -9,18 +9,17 @@ import ( nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/novox/mesh-host/internal/buckets" + "github.com/novox/mesh-host/internal/compose/frombus" engcomp "github.com/novox/mesh-host/internal/compositions" + "github.com/novox/mesh-host/internal/judge" "sort" "strings" "sync" "sync/atomic" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-host/internal/compose/frombus" - "github.com/novox/mesh-host/internal/judge" - "github.com/novox/mesh-host/internal/agent" ) @@ -165,7 +164,7 @@ func (s *Shadow) Run(ctx context.Context) error { {buckets.PlacementCandidates, ">", nil, nil}, {buckets.Placement, ">", (*Composer).takePlacement, nil}, {buckets.Have, "*." + nodeengineclient.NodeWord, nil, nil}, - {delivery.ModulesBucket, ">", (*Composer).takeModule, nil}, + {buckets.Modules, ">", (*Composer).takeModule, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, // What this node runs moved: an apply ends in its module's have. {buckets.Have, node + ".>", nil, nil}, @@ -521,7 +520,7 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengin s.mu.Unlock() c.mu.Lock() - modules := make(map[string]delivery.Module, len(c.modules)) + modules := make(map[string]meshcatalog.ModulesByModuleValue, len(c.modules)) for k, v := range c.modules { modules[k] = v } @@ -557,7 +556,7 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengin fps := shadowBuilds(running, *placement, modules, inOwn) kept := map[string]string{} for module, fp := range fps { - if mod, ok := modules[module]; ok && mod.FP2 == fp { + if mod, ok := modules[module]; ok && mod.Fp2 == fp { continue } kept[module] = fp // composed from that build (frombus.Scope.KeptAt, issue 1126) @@ -665,7 +664,7 @@ func asHavesSay(a nodeengine.CandidateAnswer, ran map[string]string) nodeengine. // of the old form) is given none, so it is folded there again, as the node's own composer folds it, rather than // composed at the trunk's build as if the candidate added it (issue 1126: nats and photos have a trunk build that no // want names). -func shadowBuilds(running []engcomp.Composed, placement meshmoduleassigner.Placement, modules map[string]delivery.Module, inOwn []string) map[string]string { +func shadowBuilds(running []engcomp.Composed, placement meshmoduleassigner.Placement, modules map[string]meshcatalog.ModulesByModuleValue, inOwn []string) map[string]string { fps := map[string]string{} for _, r := range running { if r.Module != "" && r.FP != "" { @@ -678,8 +677,8 @@ func shadowBuilds(running []engcomp.Composed, placement meshmoduleassigner.Place } for _, m := range placement.Modules { if _, runs := fps[m.Module]; !runs && !folded[m.Module] { - if mod, ok := modules[m.Module]; ok && mod.FP2 != "" { - fps[m.Module] = mod.FP2 + if mod, ok := modules[m.Module]; ok && mod.Fp2 != "" { + fps[m.Module] = mod.Fp2 } } } diff --git a/internal/userlist/userlist_test.go b/internal/userlist/userlist_test.go index 7aad01e..3fdecc0 100644 --- a/internal/userlist/userlist_test.go +++ b/internal/userlist/userlist_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "sync" @@ -17,7 +18,7 @@ import ( const node = "novox" -func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } +func fpOf(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) } // feed is a watch the test writes to: the keys there before the watch, Caught, then each put. watching is closed once // the watch began, so a put after it is never handed before the keys there before it. -- 2.54.0 From 4bfa94dfefcd07b3c0c330a45328ecd6813777d6 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:44:31 +0100 Subject: [PATCH 43/73] =?UTF-8?q?Node-engine:=20a=20builder's=20build,=20i?= =?UTF-8?q?ts=20keys=20and=20codec=20come=20from=20the=20builder's=20build?= =?UTF-8?q?s=20package=20(ADR=200634=20=C2=A71,=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-compose/main_test.go | 4 +- cmd/mesh-host/nodeagent_integration_test.go | 16 +++--- go.mod | 1 + internal/compose/frombus/answer_rows_test.go | 14 +++--- internal/compose/frombus/frombus.go | 17 ++++--- internal/compose/frombus/frombus_test.go | 28 +++++------ .../frombus/frombustest/frombustest.go | 25 +++++----- .../frombus/frombustest/frombustest_test.go | 14 +++--- internal/compose/frombus/held_rows_test.go | 25 +++++----- internal/compose/frombus/states.go | 13 +++-- internal/compose/frombus/twice.go | 10 ++-- internal/selfcompose/composedstate.go | 4 +- internal/selfcompose/composedstate_test.go | 15 +++--- internal/selfcompose/data_test.go | 18 +++---- internal/selfcompose/selfcompose.go | 25 +++++----- internal/selfcompose/selfcompose_test.go | 50 +++++++++---------- internal/takein/takein.go | 8 +-- 17 files changed, 143 insertions(+), 144 deletions(-) diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index 2ce1355..0b69063 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -282,7 +282,7 @@ func ownPackages(t *testing.T) map[string][]string { } // 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard -// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the delivery SDK's composer (the command's own request and verdict types), the private network's overlay package (mesh-wireguard's own pure package: names, graph and +// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the delivery SDK's composer (the command's own request and verdict types), the builder's builds package (a build's value and its keys), the private network's overlay package (mesh-wireguard's own pure package: names, graph and // generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or // another process. func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { @@ -294,7 +294,7 @@ func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { for path, imports := range own { for _, imp := range imports { std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") - if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/sdk/composer") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { + if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/sdk/composer") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp) } for _, f := range forbidden { diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index eda3474..f8fcfbc 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -14,6 +14,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + builderbuilds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -28,7 +29,6 @@ import ( nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -416,7 +416,7 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { } placement, modules, manifests := kvOf(buckets.Placement), kvOf(buckets.Modules), kvOf(buckets.Manifests) resolved := kvOf(selfcompose.ResolvedBucket) - builds := kvOf(delivery.BuilderBuildsBucket) + builds := kvOf(builderbuilds.BuilderBuildsBucket) candidates := kvOf(buckets.PlacementCandidates) packages := kvOf(link.PackagesBucket) path := filepath.Join(b.dir, "web.conf") @@ -447,9 +447,9 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}}) pkey2, _ := meshmoduleassignerclient.LegacyKey("it2") hkey2, _ := nec.HaveNodeKey("it2") - buildKey, _ := delivery.BuilderBuildKey("web", fp) - done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest, - Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}}) + buildKey, _ := builderbuilds.BuilderBuildKey("web", fp) + done, _ := builderbuilds.EncodeBuilderBuild(builderbuilds.BuilderBuild{State: builderbuilds.BuildDone, Manifest: digest, + Artifacts: []builderbuilds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}}) firstBuild := done if checkBuild { firstBuild = []byte(`{"v":1,"state":"no such state"}`) @@ -770,7 +770,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test } placement, modules, manifests := kvOf(buckets.Placement), kvOf(buckets.Modules), kvOf(buckets.Manifests) kvOf(selfcompose.ResolvedBucket) - builds := kvOf(delivery.BuilderBuildsBucket) + builds := kvOf(builderbuilds.BuilderBuildsBucket) kvOf(buckets.PlacementCandidates) kvOf(link.PackagesBucket) // The bus module at a build, done: its manifest written and the indexer's module naming it at fp. @@ -786,8 +786,8 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test if _, err := manifests.Put(mkey, mraw); err != nil { t.Fatal(err) } - bkey, _ := delivery.BuilderBuildKey("nats", fp) - done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest}) + bkey, _ := builderbuilds.BuilderBuildKey("nats", fp) + done, _ := builderbuilds.EncodeBuilderBuild(builderbuilds.BuilderBuild{State: builderbuilds.BuildDone, Manifest: digest}) if _, err := builds.Put(bkey, done); err != nil { t.Fatal(err) } diff --git a/go.mod b/go.mod index 46b4065..bf5e482 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0 git.novox.be/novox/mesh-delivery/sdk v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-20261105000000-000000000000 diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index ebaaf31..261f490 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -9,6 +9,7 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -18,7 +19,6 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" ) @@ -49,7 +49,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, modules: map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}}, manifests: map[string][]byte{meshcatalogclient.ManifestDigest(lemurs): lemurs}, - builds: map[string]*delivery.BuilderBuild{}} + builds: map[string]*builds.BuilderBuild{}} for _, m := range []string{"web", "api", "spiegel"} { b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) } @@ -64,8 +64,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { mod := b.modules[m] mod.Fp2 = fp(string(rune('1' + i))) b.modules[m] = mod - b.builds[m+" "+mod.Fp2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}} + b.builds[m+" "+mod.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest, + Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}} } return b } @@ -209,8 +209,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { sp.Fp2 = fp("3") bb.modules["spiegel"] = sp image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64) - bb.builds["spiegel "+sp.Fp2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: sp.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}} + bb.builds["spiegel "+sp.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: sp.Manifest, + Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}} read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, map[string]string{"lemurs": fp("e"), "spiegel": sp.Fp2}) raw, _ := json.Marshal(read.Catalogue["spiegel"]) @@ -565,7 +565,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) siteRuns.FP = fp("7") b.manifests[meshcatalogclient.ManifestDigest(site)] = site - b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(site)} + b.builds["site "+fp("7")] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(site)} got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps) if err != nil || held["site"] == "" || held["nft"] != "" { t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 899e057..9e7b302 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -40,6 +40,7 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "net/netip" @@ -78,9 +79,9 @@ type Reader interface { // and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key // the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it. Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) - // Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil + // Build is a module's build at a fingerprint, the builder's key (builds.BuilderBuildsBucket, BuilderBuildKey); nil // when the builder holds none. The composing node composes each of its modules from its build (issue 1126). - Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) + Build(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) // Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it // (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the // controller's take-in reads it (takein.Built). @@ -501,7 +502,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { // finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets. // in is the base's input, or a copy of it (base.cloned) where one base is finished more than once. -func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), +func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error), mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { var err error parse := parser(mesh) @@ -777,7 +778,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, continue } b, err := r.Build(ctx, module, c.FP) - if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" { + if err != nil || b == nil || b.State != builds.BuildDone || b.Manifest == "" { return catalogue.Manifest{}, false } raw, err := r.Manifest(ctx, b.Manifest) @@ -795,7 +796,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, // module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved // by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it // (issue 1126). -func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), +func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error), in *compose.Input, s Scope, p meshmoduleassigner.Placement, modules map[string]meshcatalog.ModulesByModuleValue, read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), refuse func(module string, err error), wanted map[string]string) error { @@ -835,7 +836,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m } return r.Archive(ctx, catalogue.Routed(reference, address), digest) } - built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) { + built := func(module, fp string, b *builds.BuilderBuild) (catalogue.Manifest, error) { if b.Manifest == "" { return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp) } @@ -920,7 +921,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m stub(pm.Module, err) continue } - if b == nil || b.State != delivery.BuildDone { + if b == nil || b.State != builds.BuildDone { if refuse != nil && runs[pm.Module] { // A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no // runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30: @@ -950,7 +951,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m switch { case err != nil: err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err) - case b == nil || b.State != delivery.BuildDone: + case b == nil || b.State != builds.BuildDone: err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp) } var resolved catalogue.Manifest diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 33a75fe..c0a4cae 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -17,6 +17,7 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/compositions" "os" "reflect" @@ -27,7 +28,6 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" ) @@ -48,7 +48,7 @@ type bus struct { asked []compose.CredentialKey // builds are the builder's keys, by " "; archives the artifact store's blobs, by digest, and fetched // every URL asked; served another node's values by ".", and servedRead every ask with its keys. - builds map[string]*delivery.BuilderBuild + builds map[string]*builds.BuilderBuild archives map[string][]byte fetched []string served map[string]map[string]any @@ -58,7 +58,7 @@ type bus struct { held bool } -func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { +func (b *bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) { b.buildsRead = append(b.buildsRead, module) return b.builds[module+" "+fp], nil } @@ -560,8 +560,8 @@ func TestInput(t *testing.T) { kept.manifests["old"] = oldXterm oldFP := "src2:" + strings.Repeat("9", 64) toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64) - kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old", - Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}} + kept.builds = map[string]*builds.BuilderBuild{"xterm " + oldFP: {State: builds.BuildDone, Manifest: "old", + Artifacts: []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}} parsedOld, err := catalogue.Decode(oldXterm) if err != nil { t.Fatal(err) @@ -617,9 +617,9 @@ func TestInput(t *testing.T) { digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), "store": "src2:" + strings.Repeat("5", 64)} - resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, + resticArtifacts := []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}} - clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}} + clientArtifacts := []builds.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}} movedMesh := func(built bool) *bus { b := one() p := b.placements["n1"] @@ -638,10 +638,10 @@ func TestInput(t *testing.T) { // The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out. b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} if built { - b.builds = map[string]*delivery.BuilderBuild{ - "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, - "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, - "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)}, + b.builds = map[string]*builds.BuilderBuild{ + "restic " + movedFps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, + "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, + "store " + movedFps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)}, } b.archives = map[string][]byte{archiveDigest: packed.Bytes()} } @@ -674,7 +674,7 @@ func TestInput(t *testing.T) { } // A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8). unreferenced := movedMesh(true) - unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]} + unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]} if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") { t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held) } @@ -866,8 +866,8 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { } for key, build := range b.builds { module, fp, _ := strings.Cut(key, " ") - k, _ := delivery.BuilderBuildKey(module, fp) - raw, err := delivery.EncodeBuilderBuild(*build) + k, _ := builds.BuilderBuildKey(module, fp) + raw, err := builds.EncodeBuilderBuild(*build) put(BuildsRead, k, raw, err) } for nm, values := range b.values { diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go index e0016d0..70a7a56 100644 --- a/internal/compose/frombus/frombustest/frombustest.go +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -16,11 +16,10 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" + "strings" ) //go:embed testdata @@ -34,9 +33,9 @@ type Bus struct { Manifests map[string][]byte Value map[string]map[string]any // by node.module Own []compose.OwnSecretRow - Builds map[string]*delivery.BuilderBuild // by " " - Archives map[string][]byte // by digest - Serves map[string]map[string]any // another node's values, by node.module + Builds map[string]*builds.BuilderBuild // by " " + Archives map[string][]byte // by digest + Serves map[string]map[string]any // another node's values, by node.module Held bool } @@ -81,7 +80,7 @@ func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider [] return out, issued, nil } -func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { +func (b *Bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) { return b.Builds[module+" "+fp], nil } @@ -164,15 +163,15 @@ func Provisioned() (*Bus, error) { SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, Module: map[string]meshcatalog.ModulesByModuleValue{}, Manifests: map[string][]byte{}, Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}, - Builds: map[string]*delivery.BuilderBuild{ - "restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{ + Builds: map[string]*builds.BuilderBuild{ + "restic " + fps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: []builds.BuiltArtifact{ {Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}}, - "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), - Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", + "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), + Artifacts: []builds.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}}, - "store " + fps["store"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)}, - "postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest(postgres)}, + "store " + fps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)}, + "postgres " + fps["postgres"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(postgres)}, }, Archives: map[string][]byte{archive: packed.Bytes()}, Held: true, diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index e0ac451..3593057 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -9,6 +9,7 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" engcomp "github.com/novox/mesh-host/internal/compositions" "reflect" "slices" @@ -17,7 +18,6 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/fromruntime" ) @@ -84,8 +84,8 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { if row.built != "" { builtDigest := meshcatalogclient.ManifestDigest([]byte(row.built)) b.Manifests[builtDigest] = []byte(row.built) - b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, - Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} + b.Builds["backup "+fp] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: builtDigest, + Artifacts: []builds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} } if row.dataOnly { items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) @@ -274,10 +274,10 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &built want = "" } else if row.filledHeld { - bb.Builds["restic "+fps["restic"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding} + bb.Builds["restic "+fps["restic"]] = &builds.BuilderBuild{State: builds.BuildBuilding} } else if row.placed { // held: its build not done while it runs - bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding} + bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &builds.BuilderBuild{State: builds.BuildBuilding} } else { // taken out of the node's placement while what runs still names it: the account goes with it, never kept stale p := bb.Placement[Node] @@ -334,8 +334,8 @@ func statesOf(t *testing.T, b *Bus) frombus.States { } for at, build := range b.Builds { module, fp, _ := strings.Cut(at, " ") - key, _ := delivery.BuilderBuildKey(module, fp) - raw, err := delivery.EncodeBuilderBuild(*build) + key, _ := builds.BuilderBuildKey(module, fp) + raw, err := builds.EncodeBuilderBuild(*build) put(frombus.BuildsRead, key, raw, err) } return s diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index a60ec47..4936bb1 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -9,13 +9,12 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-host/internal/compose" - "github.com/novox/mesh-host/internal/compose/resolution" ) // aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. @@ -23,7 +22,7 @@ func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { t.Helper() p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, - modules: map[string]meshcatalog.ModulesByModuleValue{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} + modules: map[string]meshcatalog.ModulesByModuleValue{}, manifests: map[string][]byte{}, builds: map[string]*builds.BuilderBuild{}} fps := map[string]string{} for i, raw := range manifests { module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0] @@ -148,12 +147,12 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { mod := b.modules["node-tools"] mod.Fp2 = next b.modules["node-tools"] = mod - b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}} - b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + b.builds["node-tools "+old] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest, + Artifacts: []builds.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}} + b.builds["node-tools "+next] = &builds.BuilderBuild{State: "building", Manifest: mod.Manifest} if row.done { - b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}} + b.builds["node-tools "+next] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest, + Artifacts: []builds.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}} } fps["node-tools"] = row.want out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps) @@ -258,10 +257,10 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { mod := b.modules[m] mod.Fp2 = fps[m] b.modules[m] = mod - b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}} + b.builds[m+" "+fps[m]] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest, + Artifacts: []builds.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}} if m == "node-tools" && row.building { - b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + b.builds[m+" "+fps[m]] = &builds.BuilderBuild{State: "building", Manifest: mod.Manifest} } } out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index 501005a..2ae2480 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -18,12 +18,11 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" + "strings" + "time" ) // The states a module names in its manifest's `reads` to compose through StatesReader. @@ -161,8 +160,8 @@ func (statesReader) Credentials(context.Context, string, []compose.CredentialKey return nil, nil, nil } -func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { - key, err := delivery.BuilderBuildKey(module, fp) +func (r statesReader) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) { + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return nil, err } @@ -170,7 +169,7 @@ func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.Bui if err != nil || !found { return nil, err } - b, err := delivery.DecodeBuilderBuild(v) + b, err := builds.DecodeBuilderBuild(v) if err != nil { return nil, err } diff --git a/internal/compose/frombus/twice.go b/internal/compose/frombus/twice.go index e9738dd..4411c51 100644 --- a/internal/compose/frombus/twice.go +++ b/internal/compose/frombus/twice.go @@ -12,11 +12,11 @@ import ( "encoding/hex" "encoding/json" "fmt" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "sync" "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's. @@ -75,13 +75,13 @@ func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newe // onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the // builds of the same moment. -func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { +func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) { type answer struct { - b *delivery.BuilderBuild + b *builds.BuilderBuild err error } read := map[string]answer{} - return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { + return func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) { key := module + " " + fp if a, ok := read[key]; ok { return a.b, a.err @@ -101,7 +101,7 @@ type takenInKeeper interface { // takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read // whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes. -func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) { +func takenInKey(module, fp, address string, b builds.BuilderBuild) (string, bool) { raw, err := json.Marshal(b) if err != nil { return "", false diff --git a/internal/selfcompose/composedstate.go b/internal/selfcompose/composedstate.go index 5693f90..5c4dab7 100644 --- a/internal/selfcompose/composedstate.go +++ b/internal/selfcompose/composedstate.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" "sort" @@ -11,7 +12,6 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/compose/frombus" @@ -93,7 +93,7 @@ func (c *Composer) newestFPs(ctx context.Context, at *published) map[string]stri fp2 := c.modules[module].Fp2 c.mu.Unlock() if fp2 != "" { - if b, err := r.Build(ctx, module, fp2); err == nil && b != nil && b.State == delivery.BuildDone { + if b, err := r.Build(ctx, module, fp2); err == nil && b != nil && b.State == builds.BuildDone { out[module] = fp2 continue } diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index 4cf6e82..9f57406 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -9,6 +9,7 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -18,8 +19,6 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-host/internal/agent" ) @@ -105,7 +104,7 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { b.put(t, buckets.Modules, "oldie", omod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } for u := next(t, out); !u.Caught; u = next(t, out) { @@ -147,17 +146,17 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { t.Errorf("a pass that changed nothing wrote %v", got[2:]) } // oldie's trunk build is done: its key at that build is written once, though no want names it. - build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: odigest}) - bkey, _ := delivery.BuilderBuildKey("oldie", ofp) + build, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: odigest}) + bkey, _ := builds.BuilderBuildKey("oldie", ofp) b.mu.Lock() - b.kv[delivery.BuilderBuildsBucket+"/"+bkey] = build + b.kv[builds.BuilderBuildsBucket+"/"+bkey] = build b.mu.Unlock() - b.put(t, delivery.BuilderBuildsBucket, bkey, build) + b.put(t, builds.BuilderBuildsBucket, bkey, build) got := waitFor("oldie's done build", func(got []string) bool { return len(got) >= 3 }) if got[2] != oldieKey { t.Errorf("the done build of oldie wrote %v, want %s", got[2:], oldieKey) } - b.put(t, delivery.BuilderBuildsBucket, bkey, build) + b.put(t, builds.BuilderBuildsBucket, bkey, build) time.Sleep(100 * time.Millisecond) if got := state.written(); len(got) != 3 { t.Errorf("the same build landing again wrote %v", got[3:]) diff --git a/internal/selfcompose/data_test.go b/internal/selfcompose/data_test.go index d2273d3..04bca2b 100644 --- a/internal/selfcompose/data_test.go +++ b/internal/selfcompose/data_test.go @@ -6,6 +6,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "slices" @@ -15,7 +16,6 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/agent" @@ -235,15 +235,15 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { // The items are worked out from what the pass read: a done build is read over the bus once, not again for the data // items after the pass (a module in a running build is read again, since it may land). b := newFakeBus() - done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest([]byte(lemurs))}) - running, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: "building"}) + done, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest([]byte(lemurs))}) + running, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: "building"}) fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) - doneKey, _ := delivery.BuilderBuildKey("web", fp) - runningKey, _ := delivery.BuilderBuildKey("pg", fp) - b.kv[delivery.BuilderBuildsBucket+"/"+doneKey], b.kv[delivery.BuilderBuildsBucket+"/"+runningKey] = done, running + doneKey, _ := builds.BuilderBuildKey("web", fp) + runningKey, _ := builds.BuilderBuildKey("pg", fp) + b.kv[builds.BuilderBuildsBucket+"/"+doneKey], b.kv[builds.BuilderBuildsBucket+"/"+runningKey] = done, running r := reader{c: New(b.env())} for range 3 { - if got, err := r.Build(context.Background(), "web", fp); err != nil || got.State != delivery.BuildDone { + if got, err := r.Build(context.Background(), "web", fp); err != nil || got.State != builds.BuildDone { t.Fatalf("the done build: %+v, %v", got, err) } if got, err := r.Build(context.Background(), "pg", fp); err != nil || got.State != "building" { @@ -258,9 +258,9 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) { t.Errorf("the builds were read %v times over the bus; want the done one once, the running one each time", reads) } // A done key the builder claims again (a gone step, issue 1937) and lands done again is read again, not served kept. - again, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: meshcatalogclient.ManifestDigest([]byte("{}"))}) + again, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest([]byte("{}"))}) b.mu.Lock() - b.kv[delivery.BuilderBuildsBucket+"/"+doneKey] = again + b.kv[builds.BuilderBuildsBucket+"/"+doneKey] = again b.mu.Unlock() r.c.takeBuildDone(agent.Update{Key: doneKey, Value: running}) if got, err := r.Build(context.Background(), "web", fp); err != nil || got.Manifest != meshcatalogclient.ManifestDigest([]byte("{}")) { diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index ce02dfd..cee73ed 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -26,6 +26,7 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -150,7 +151,7 @@ type Composer struct { archives map[string][]byte // templates archives by digest, read once // builds are the builder's done builds by key, read once: a done build at a fingerprint never changes, so a pass and // the data items after it (publishData) do not read each placed module's build again over the bus (ADR 0577). - builds map[string]delivery.BuilderBuild + builds map[string]builds.BuilderBuild handed map[string]string // composed key to the digest handed // sealedOwn is each own secret's sealed value as first handed, by module/name and generation: a value sealed again // is other bytes, so the one first had is kept while the generation stands, and the composition stays the same. @@ -307,7 +308,7 @@ func New(env Env) *Composer { } return &Composer{env: env, wants: map[string]meshwants.WantsByWantNodeModuleValue{}, placements: map[string]meshmoduleassigner.Placement{}, placementKeys: meshmoduleassignerclient.NewKeys(), records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]meshcatalog.ModulesByModuleValue{}, values: map[string]map[string]any{}, - manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]delivery.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, + manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]builds.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} } @@ -371,7 +372,7 @@ func (c *Composer) inputs() []input { {ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, // The builder's builds: a pass that held a module for a build not yet done is made again when it lands (issue // 774, 2026-10-30 19:30: node-tools' build landed and ace, g14 and shanks stayed refused until another input moved). - {delivery.BuilderBuildsBucket, ">", nil, (*Composer).takeBuildDone}, + {builds.BuilderBuildsBucket, ">", nil, (*Composer).takeBuildDone}, } } @@ -386,7 +387,7 @@ func (c *Composer) takeBuildDone(u agent.Update) bool { if u.Deleted { return false } - module, fp, err := delivery.ParseBuilderBuildKey(u.Key) + module, fp, err := builds.ParseBuilderBuildKey(u.Key) if err != nil { return false } @@ -403,8 +404,8 @@ func (c *Composer) takeBuildDone(u agent.Update) bool { if !(wanted && w.Fp == fp) && !((wanted || placedHere) && m.Fp2 == fp) { return false } - b, err := delivery.DecodeBuilderBuild(u.Value) - return err == nil && b.State == delivery.BuildDone + b, err := builds.DecodeBuilderBuild(u.Value) + return err == nil && b.State == builds.BuildDone } // Watch starts the composer and answers the compositions it makes: each as a key of `composed-` would be, then a @@ -1429,8 +1430,8 @@ func (r reader) Manifest(ctx context.Context, digest string) ([]byte, error) { return []byte(m.Manifest), nil } -func (r reader) Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { - key, err := delivery.BuilderBuildKey(module, fp) +func (r reader) Build(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) { + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return nil, err } @@ -1440,16 +1441,16 @@ func (r reader) Build(ctx context.Context, module, fp string) (*delivery.Builder if ok { return &done, nil } - defer r.c.waiting("the build of " + module + " at " + fp + " (" + delivery.BuilderBuildsBucket + ")")() - raw, err := r.c.get(ctx, delivery.BuilderBuildsBucket, key) + defer r.c.waiting("the build of " + module + " at " + fp + " (" + builds.BuilderBuildsBucket + ")")() + raw, err := r.c.get(ctx, builds.BuilderBuildsBucket, key) if err != nil || raw == nil { return nil, err } - b, err := delivery.DecodeBuilderBuild(raw) + b, err := builds.DecodeBuilderBuild(raw) if err != nil { return nil, fmt.Errorf("the builder's %s: %w", key, err) } - if b.State == delivery.BuildDone { + if b.State == builds.BuildDone { r.c.keepRead(func(k *Composer) { k.builds[key] = b }) } return &b, nil diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index dcbaa2c..ecce6b7 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -15,6 +15,7 @@ import ( "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" @@ -29,7 +30,6 @@ import ( "time" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" @@ -209,7 +209,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.put(t, buckets.Modules, "oldie", omod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } got := map[string]bool{} @@ -570,22 +570,22 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { pass bool }{ {"lemurs's wanted build, not done", "lemurs", fp, "building", false}, - {"lemurs's wanted build lands", "lemurs", fp, delivery.BuildDone, true}, - {"another build of lemurs", "lemurs", meshcatalogclient.SourcePrefix2 + strings.Repeat("e", 64), delivery.BuildDone, false}, - {"a placed module no want names, at its trunk's build", "oldie", meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, true}, - {"a module neither wanted nor placed here", "ghost", meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), delivery.BuildDone, false}, + {"lemurs's wanted build lands", "lemurs", fp, builds.BuildDone, true}, + {"another build of lemurs", "lemurs", meshcatalogclient.SourcePrefix2 + strings.Repeat("e", 64), builds.BuildDone, false}, + {"a placed module no want names, at its trunk's build", "oldie", meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), builds.BuildDone, true}, + {"a module neither wanted nor placed here", "ghost", meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), builds.BuildDone, false}, } { before := passes() - key, _ := delivery.BuilderBuildKey(row.module, row.fp) - build := delivery.BuilderBuild{State: row.state} - if row.state == delivery.BuildDone { + key, _ := builds.BuilderBuildKey(row.module, row.fp) + build := builds.BuilderBuild{State: row.state} + if row.state == builds.BuildDone { build.Manifest = digest } - raw, err := delivery.EncodeBuilderBuild(build) + raw, err := builds.EncodeBuilderBuild(build) if err != nil { t.Fatal(err) } - b.put(t, delivery.BuilderBuildsBucket, key, raw) + b.put(t, builds.BuilderBuildsBucket, key, raw) quiet(t, out) if got := passes() > before; got != row.pass { t.Errorf("%s: a pass made %v, want %v", row.name, got, row.pass) @@ -700,15 +700,15 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { Modules: []meshmoduleassigner.PlacedModule{{Module: "tool"}}}) tmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: next2, Manifest: tdigest, Repository: "novox/t", Commit: "c"}) twant, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: next2, Self: true}) - building, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: "building"}) - bkey, _ := delivery.BuilderBuildKey("tool", next2) - tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = building + building, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: "building"}) + bkey, _ := builds.BuilderBuildKey("tool", next2) + tb.kv[builds.BuilderBuildsBucket+"/"+bkey] = building tb.put(t, buckets.Placement, "placement.n1", tplace) tb.put(t, buckets.Have, "n1.node", have) tb.put(t, buckets.Modules, "tool", tmod) tb.put(t, buckets.Wants, "want.n1.tool", twant) for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, - ResolvedBucket, delivery.BuilderBuildsBucket} { + ResolvedBucket, builds.BuilderBuildsBucket} { tb.send(bucket, agent.Update{Caught: true}) } toolKey, _ := compositions.Key("n1", "tool", next2) @@ -722,13 +722,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Errorf("the node was told %q, want tool held for its build not yet done", held) } hmu.Unlock() - done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: tdigest, - Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://tool/files/blobs/sha256:" + strings.Repeat("9", 64), + done, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: tdigest, + Artifacts: []builds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://tool/files/blobs/sha256:" + strings.Repeat("9", 64), Digest: "sha256:" + strings.Repeat("9", 64)}}}) tb.mu.Lock() - tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = done + tb.kv[builds.BuilderBuildsBucket+"/"+bkey] = done tb.mu.Unlock() - tb.put(t, delivery.BuilderBuildsBucket, bkey, done) + tb.put(t, builds.BuilderBuildsBucket, bkey, done) if u := next(t, tout); u.Key != toolKey || u.Deleted { t.Errorf("the build landed and the node handed %s, want tool at its new build", u.Key) } @@ -1370,7 +1370,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { oldRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "older", Placement: &trunk}) oldKey := meshmoduleassigner.CandidatesByProposalNodeKey("older", "n1") nb.put(buckets.PlacementCandidates, oldKey, oldRaw, 2) - for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, buckets.Modules, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, buckets.Modules, builds.BuilderBuildsBucket} { nb.send(bucket, agent.Update{Caught: true}) } select { @@ -1461,10 +1461,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Issue 1126, the seed's refusals: what a node composes from its builds and reads of a provider elsewhere. A build // is the builder's key; a templates archive is fetched once per digest and refused when it is not that digest; another // node's served key is read alone, as the node's grant allows it (buscompose Principal.ServedReads). - built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest}) - buildKey, _ := delivery.BuilderBuildKey("lemurs", fp) + built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: digest}) + buildKey, _ := builds.BuilderBuildKey("lemurs", fp) b.mu.Lock() - b.kv[delivery.BuilderBuildsBucket+"/"+buildKey] = built + b.kv[builds.BuilderBuildsBucket+"/"+buildKey] = built b.kv[ResolvedBucket+"/n2.mailer.domain"] = []byte(`{"value":"example.org","source":"module"}`) b.reads = nil b.mu.Unlock() @@ -1691,7 +1691,7 @@ func (r fixedReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow func (r fixedReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { return nil, nil, nil } -func (r fixedReader) Build(context.Context, string, string) (*delivery.BuilderBuild, error) { +func (r fixedReader) Build(context.Context, string, string) (*builds.BuilderBuild, error) { return nil, nil } func (r fixedReader) Archive(_ context.Context, url, _ string) ([]byte, error) { @@ -1800,7 +1800,7 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { b.put(t, buckets.Modules, "lemurs", mod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, delivery.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } handed := map[string]bool{} diff --git a/internal/takein/takein.go b/internal/takein/takein.go index 657ab45..a47bb27 100644 --- a/internal/takein/takein.go +++ b/internal/takein/takein.go @@ -18,11 +18,11 @@ import ( "context" "errors" "fmt" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "io" "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // Archive reads the archive a recorded reference names (catalogue.Recorded: the artifact store's address left out), @@ -33,8 +33,8 @@ type Archive func(ctx context.Context, reference, digest string) ([]byte, error) // and every artifact must name its reference. // A templated module's templates archive is read through archive; a module without templates reads none, and archive // may then be nil. -func Manifest(ctx context.Context, m catalogue.Manifest, b delivery.BuilderBuild, archive Archive) (catalogue.Manifest, error) { - if b.State != delivery.BuildDone { +func Manifest(ctx context.Context, m catalogue.Manifest, b builds.BuilderBuild, archive Archive) (catalogue.Manifest, error) { + if b.State != builds.BuildDone { return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is %s, not done: nothing to resolve it with", m.Module, b.State) } // **A done key whose artifact names no reference is waited for, never composed from** (novox/hq ADR 0498 §8), in the @@ -64,7 +64,7 @@ func Manifest(ctx context.Context, m catalogue.Manifest, b delivery.BuilderBuild // Built is each of a done key's artifacts as catalogue.Manifest.Resolve reads it: its recorded reference and digest, a // TypeScript bundle's launchers by the build modules' one rule (catalogue.Launchers), and, for the archive a module packs // its templates into, each template's text read back from that archive. -func Built(ctx context.Context, m catalogue.Manifest, artifacts []delivery.BuiltArtifact, archive Archive) ([]catalogue.Built, error) { +func Built(ctx context.Context, m catalogue.Manifest, artifacts []builds.BuiltArtifact, archive Archive) ([]catalogue.Built, error) { declared := map[string]catalogue.Artifact{} if m.Build != nil { for _, a := range m.Build.Artifacts { -- 2.54.0 From 13e2fe14aa7c7ab6c8f142f2a50f084b33d8f20f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:47:59 +0100 Subject: [PATCH 44/73] =?UTF-8?q?Node-engine:=20logins,=20signers,=20enrol?= =?UTF-8?q?ment=20tokens=20and=20redeems,=20and=20the=20grants=20part's=20?= =?UTF-8?q?sealed=20user=20list=20come=20from=20the=20vault=20and=20node-e?= =?UTF-8?q?nroller=20clients=20and=20the=20grants=20state=20package;=20no?= =?UTF-8?q?=20file=20imports=20go/delivery=20(ADR=200634=20=C2=A71,=20?= =?UTF-8?q?=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/authority_test.go | 40 ++++++------ cmd/mesh-host/enrol_seat.go | 13 ++-- cmd/mesh-host/logins.go | 13 ++-- cmd/mesh-host/main.go | 4 +- cmd/mesh-host/trust.go | 40 ++++++------ cmd/mesh-host/userlist.go | 4 +- go.mod | 3 + internal/agent/agent_test.go | 4 +- internal/buckets/buckets.go | 13 ++++ internal/compose/frombus/frombus.go | 10 +-- internal/identity/token.go | 16 ++--- internal/link/enrol_seat.go | 68 +++++++++++---------- internal/link/enrol_seat_test.go | 41 +++++++------ internal/link/files.go | 4 +- internal/link/logins.go | 16 ++--- internal/link/logins_test.go | 9 ++- internal/link/rotate.go | 19 +++--- internal/link/rotate_integration_test.go | 16 ++--- internal/link/rotate_test.go | 24 ++++---- internal/selfcompose/selfcompose.go | 4 +- internal/userlist/userlist.go | 29 +++++---- internal/userlist/userlist_test.go | 77 ++++++++++++------------ 22 files changed, 248 insertions(+), 219 deletions(-) diff --git a/cmd/mesh-host/authority_test.go b/cmd/mesh-host/authority_test.go index 09f7333..1287491 100644 --- a/cmd/mesh-host/authority_test.go +++ b/cmd/mesh-host/authority_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" @@ -16,7 +17,6 @@ import ( "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/identity" ) @@ -52,38 +52,38 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { told := 0 trust.onChange(func() { told++ }) keys := recordKeys(state, trust.Trusts) - ctrlFP, vaultFP := delivery.SignerFingerprint(controllerPub), delivery.SignerFingerprint(vaultPub) - enrol, _ := delivery.SignContext(delivery.KindEnrolment) + ctrlFP, vaultFP := meshvaultclient.SignerFingerprint(controllerPub), meshvaultclient.SignerFingerprint(vaultPub) + enrol, _ := meshvaultclient.SignContext(meshvaultclient.KindEnrolment) msg := []byte("a reply") if got := keys().Trusts; len(got) != 0 { t.Errorf("before the signers are read the record trusts %v", got) } - if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil { + if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil { t.Error("a controller-signed message is accepted") } - if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err == nil { + if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err == nil { t.Error("a vault-signed message is accepted before the vault's key is read") } vaultCA := testAuthority(t, vaultPub, vault) - keyValue, _ := json.Marshal(delivery.NewSignerKey(vaultPub, time.Now())) - fp, _ := delivery.AuthorityFingerprint(vaultCA) - authValue, _ := json.Marshal(delivery.SignerAuthority{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()}) - trust.heardSigner(delivery.SignersKeyKey, keyValue, false, func(string) {}) - trust.heardSigner(delivery.SignersAuthorityKey, authValue, false, func(string) {}) - if s, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err != nil || s.Name != delivery.SignerVault { + keyValue, _ := json.Marshal(meshvaultclient.NewSignerKey(vaultPub, time.Now())) + fp, _ := meshvaultclient.AuthorityFingerprint(vaultCA) + authValue, _ := json.Marshal(meshvault.SignersByAuthorityValue{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()}) + trust.heardSigner(meshvault.SignersByKeyKey(), keyValue, false, func(string) {}) + trust.heardSigner(meshvault.SignersByAuthorityKey(), authValue, false, func(string) {}) + if s, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err != nil || s.Name != meshvaultclient.SignerVault { t.Errorf("a vault-signed message on a node that read signers: %+v, %v", s, err) } - if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil { + if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil { t.Error("a controller-signed message is accepted once the vault's key is read") } - if got := keys().Trusts; strings.Contains(strings.Join(got, " "), ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) || + if got := keys().Trusts; strings.Contains(strings.Join(got, " "), ctrlFP) || !meshvaultclient.TrustsKey(got, meshvaultclient.SignerVault, vaultFP) || trust.VaultAuthority() != vaultCA || told != 2 { t.Errorf("after the signers are read the record trusts %v (told %d)", got, told) } // A signers' key naming another fingerprint is not trusted. - bad, _ := json.Marshal(delivery.SignerKey{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP}) - trust.heardSigner(delivery.SignersKeyKey, bad, false, func(string) {}) - if !delivery.TrustsKey(keys().Trusts, delivery.SignerVault, vaultFP) { + bad, _ := json.Marshal(meshvault.SignersByKeyValue{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP}) + trust.heardSigner(meshvault.SignersByKeyKey(), bad, false, func(string) {}) + if !meshvaultclient.TrustsKey(keys().Trusts, meshvaultclient.SignerVault, vaultFP) { t.Error("a signers' key that does not check replaced the vault's") } @@ -98,8 +98,8 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { } servingPub, _ := base64.StdEncoding.DecodeString(serving.Public) signedBy := func(ca string, key ed25519.PrivateKey) string { - parent, _ := delivery.ParseCertificate(ca) - der, err := x509.CreateCertificate(nil, delivery.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)), + parent, _ := meshvaultclient.ParseCertificate(ca) + der, err := x509.CreateCertificate(nil, meshvaultclient.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)), parent, ed25519.PublicKey(servingPub), key) if err != nil { t.Fatal(err) @@ -110,7 +110,7 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { answer := signedBy(vaultCA, vault) ask := func(_ context.Context, verb string, args any) (json.RawMessage, error) { asked = append(asked, verb, args) - return json.Marshal(meshvault.SignAnswer{Kind: delivery.KindNodeCertificate, Node: "n1", Certificate: answer}) + return json.Marshal(meshvault.SignAnswer{Kind: meshvaultclient.KindNodeCertificate, Node: "n1", Certificate: answer}) } written := 0 if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err != nil { @@ -160,7 +160,7 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) { // testAuthority is a self-signed authority certificate for key, as the vault makes its own. func testAuthority(t *testing.T, pub ed25519.PublicKey, key ed25519.PrivateKey) string { t.Helper() - tpl := delivery.AuthorityTemplate(pub, time.Now().Add(-time.Hour)) + tpl := meshvaultclient.AuthorityTemplate(pub, time.Now().Add(-time.Hour)) der, err := x509.CreateCertificate(nil, tpl, tpl, pub, key) if err != nil { t.Fatal(err) diff --git a/cmd/mesh-host/enrol_seat.go b/cmd/mesh-host/enrol_seat.go index 94cb23b..005dc44 100644 --- a/cmd/mesh-host/enrol_seat.go +++ b/cmd/mesh-host/enrol_seat.go @@ -6,10 +6,9 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient" "os" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" ) @@ -21,7 +20,7 @@ import ( func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, node string, mine identity.Identity, sealing identity.SealingKey, serving identity.ServingKey, reported map[string]any, found *link.Tunnel, identityPath string) error { - req := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public, + req := meshnodeenrollerclient.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public, SealingKey: sealing.Public, ServingKey: serving.Public, Profile: reported} if found != nil { var err error @@ -29,18 +28,18 @@ func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, n return err } } - req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(delivery.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey))) + req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(meshnodeenrollerclient.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey))) to := link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS} membership := link.Membership{Node: node, Broker: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS} j := link.Joining{Node: node, Signer: token.SignerKey(), - Redeem: func(ctx context.Context) (delivery.RedeemAnswer, error) { + Redeem: func(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) { return link.Redeem(ctx, to, node, token.Secret, req, opts.timeout) }, - Open: func(a delivery.RedeemAnswer) (string, error) { + Open: func(a meshnodeenrollerclient.RedeemAnswer) (string, error) { value, err := sealing.Open(a.Account) return string(value), err }, - Keep: func(a delivery.RedeemAnswer, password string) error { + Keep: func(a meshnodeenrollerclient.RedeemAnswer, password string) error { // The mesh's name for this node wins over what the machine called itself, as on the controller's path. mine.Node = a.Node mine.Membership = identity.Membership{Broker: token.Broker, Fingerprint: token.Fingerprint, Signer: token.Signer, diff --git a/cmd/mesh-host/logins.go b/cmd/mesh-host/logins.go index ab29a6f..a8ba164 100644 --- a/cmd/mesh-host/logins.go +++ b/cmd/mesh-host/logins.go @@ -4,12 +4,13 @@ import ( "context" "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "github.com/novox/mesh-host/internal/buckets" "os" "path/filepath" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" ) @@ -48,7 +49,7 @@ var loginsFollowAgain = 2 * time.Minute // followLogins follows this node's key of the vault's node-logins on every link, as a watch (never a poll), for as long as ctx // lasts, and hands each value to hear. A watch that cannot be made is said once and tried again. -func followLogins(ctx context.Context, session *link.Session, node string, hear func(delivery.NodeLogin), say link.Announce) { +func followLogins(ctx context.Context, session *link.Session, node string, hear func(meshvault.NodeLoginsByNodeValue), say link.Announce) { said := "" for ctx.Err() == nil { linked, cancel, err := session.WhenLinked(ctx) @@ -56,7 +57,7 @@ func followLogins(ctx context.Context, session *link.Session, node string, hear return } watching, stop := context.WithCancel(linked) - updates, werr := session.KVWatch(watching, delivery.NodeLoginsBucket, node) + updates, werr := session.KVWatch(watching, buckets.NodeLogins, node) if werr != nil { stop() if line := "this node's login is not followed yet (" + werr.Error() + "): it dials as it last did, and looks again in " + loginsFollowAgain.String(); line != said { @@ -69,9 +70,9 @@ func followLogins(ctx context.Context, session *link.Session, node string, hear if u.Caught || u.Deleted || len(u.Value) == 0 { continue } - nl, err := delivery.DecodeNodeLogin(u.Value) + nl, err := meshvaultclient.DecodeNodeLogin(u.Value) if err != nil { - say("the key " + node + " of " + delivery.NodeLoginsRead + " is not read: " + err.Error()) + say("the key " + node + " of " + buckets.NodeLoginsRead + " is not read: " + err.Error()) continue } hear(nl) diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 812222f..dd0940d 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -16,6 +16,7 @@ import ( "errors" "flag" "fmt" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "io" "net" "os" @@ -30,7 +31,6 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/apply" @@ -1075,7 +1075,7 @@ func runLink(ctx context.Context, opts options) error { }, say) // And the login it dials as, which the vault's node-logins names for this node (novox/hq ADR 0583 §1). rotator := newRotator(queue, opts.state, opts.timeout, say) - go followLogins(aside, queue, mine.Node, func(nl delivery.NodeLogin) { + go followLogins(aside, queue, mine.Node, func(nl meshvault.NodeLoginsByNodeValue) { if line := membership.Logins.Heard(nl); line != "" { say(line) } diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 4eb1e7d..5857944 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -9,6 +9,7 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" @@ -21,7 +22,6 @@ import ( "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" @@ -51,23 +51,23 @@ func (t *meshTrust) onChange(f func()) { } // Signers are the keys a mesh signature is verified against now: the vault's once read, and none before. -func (t *meshTrust) Signers() []delivery.Signer { +func (t *meshTrust) Signers() []meshvaultclient.Signer { t.mu.Lock() defer t.mu.Unlock() - var out []delivery.Signer + var out []meshvaultclient.Signer if t.vault != nil { - out = append(out, delivery.Signer{Name: delivery.SignerVault, Key: t.vault}) + out = append(out, meshvaultclient.Signer{Name: meshvaultclient.SignerVault, Key: t.vault}) } return out } // Verify is the signer that signed msg as kind, or why none it trusts did. -func (t *meshTrust) Verify(kind string, msg, sig []byte) (delivery.Signer, error) { - context, err := delivery.SignContext(kind) +func (t *meshTrust) Verify(kind string, msg, sig []byte) (meshvaultclient.Signer, error) { + context, err := meshvaultclient.SignContext(kind) if err != nil { - return delivery.Signer{}, err + return meshvaultclient.Signer{}, err } - return delivery.VerifyBy(t.Signers(), context, msg, sig) + return meshvaultclient.VerifyBy(t.Signers(), context, msg, sig) } // VaultAuthority is the vault's authority certificate as read from `signers`, or "". @@ -87,9 +87,9 @@ func (t *meshTrust) Trusts() []string { } authorities := map[string]string{} if a := t.VaultAuthority(); a != "" { - authorities[delivery.SignerVault] = a + authorities[meshvaultclient.SignerVault] = a } - return delivery.Trusts(t.Signers(), authorities) + return meshvaultclient.Trusts(t.Signers(), authorities) } // change applies f under the lock and tells who follows when Trusts differs after it. @@ -108,25 +108,25 @@ func (t *meshTrust) change(f func()) { // check; a deleted key is no longer trusted. func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link.Announce) { switch key { - case delivery.SignersKeyKey: + case meshvault.SignersByKeyKey(): var pub ed25519.PublicKey if !deleted && len(value) > 0 { - k, err := delivery.DecodeSignerKey(value) + k, err := meshvaultclient.DecodeSignerKey(value) if err == nil { - pub, err = k.PublicKey() + pub, err = meshvaultclient.SignerPublicKey(k) } if err != nil { - say("the vault's signing key in " + delivery.SignersRead + " is not trusted: " + err.Error()) + say("the vault's signing key in " + buckets.SignersRead + " is not trusted: " + err.Error()) return } } t.change(func() { t.vault = pub }) - case delivery.SignersAuthorityKey: + case meshvault.SignersByAuthorityKey(): pem := "" if !deleted && len(value) > 0 { - a, err := delivery.DecodeSignerAuthority(value) + a, err := meshvaultclient.DecodeSignerAuthority(value) if err != nil { - say("the vault's authority in " + delivery.SignersRead + " is not trusted: " + err.Error()) + say("the vault's authority in " + buckets.SignersRead + " is not trusted: " + err.Error()) return } pem = a.Certificate @@ -157,7 +157,7 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s return } watching, stop := context.WithCancel(linked) - signers, serr := session.KVWatch(watching, delivery.SignersBucket, ">") + signers, serr := session.KVWatch(watching, buckets.Signers, ">") if serr != nil { stop() sayOnce(fmt.Sprintf("what this node trusts a mesh signature from is not followed yet (the vault's signers: %v): "+ @@ -266,7 +266,7 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust, if err != nil || len(key) != ed25519.PublicKeySize { return fmt.Errorf("this node's serving key is not an ed25519 key") } - raw, err := ask(ctx, meshvault.VerbSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node}) + raw, err := ask(ctx, meshvault.VerbSign, map[string]any{"kind": meshvaultclient.KindNodeCertificate, "node": node}) if err != nil { return err } @@ -278,7 +278,7 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust, if trusted == "" { return fmt.Errorf("this node has read no authority of the vault's to check the certificate against") } - if err := delivery.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node), + if err := meshvaultclient.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node), ed25519.PublicKey(key), time.Now()); err != nil { return err } diff --git a/cmd/mesh-host/userlist.go b/cmd/mesh-host/userlist.go index 8f531fb..a998447 100644 --- a/cmd/mesh-host/userlist.go +++ b/cmd/mesh-host/userlist.go @@ -5,12 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" engcomp "github.com/novox/mesh-host/internal/compositions" "os" "path/filepath" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/seal" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/identity" @@ -44,7 +44,7 @@ func userListFollower(opts options, session *link.Session, running func() []engc }, // From take-over, the grants part's list (ADR 0547 clauses 7, 8): its one key watched, its object read by name and // opened with this machine's sealing key, the key's public half said in the have so the grants part seals to it. - Grants: bucketWatch{session, delivery.GrantsUserListBucket}, + Grants: bucketWatch{session, grantsstate.GrantsUserListBucket}, Fetch: func(ctx context.Context, name string) ([]byte, error) { return session.GrantsObject(ctx, name, grantsListLimit) }, diff --git a/go.mod b/go.mod index bf5e482..4165d0b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0 git.novox.be/novox/mesh-delivery/sdk v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-20261105000000-000000000000 diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 46155a0..36d826c 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -10,6 +10,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" + "github.com/novox/mesh-host/internal/judge" "maps" "slices" "strings" @@ -20,7 +21,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" ) const node = "n1" @@ -1692,7 +1692,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( t.Errorf("the composition of db waiting for a yes reads %+v (%v)", view, err) } if view, err := b.ag.Composition(context.Background(), "db", ddig, false); err != nil || view.Composition.Digest != ddig || - view.Judged.Judgement != string(delivery.JudgedUnchanged) || view.Judged.WaitsForYes || view.Diff != nil { + view.Judged.Judgement != judge.JudgedUnchanged || view.Judged.WaitsForYes || view.Diff != nil { t.Errorf("db's running revision, asked by its digest, reads %+v (%v)", view, err) } if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil { diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index 53b0479..13f551a 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -5,6 +5,7 @@ package buckets import ( "errors" + vault "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" assigner "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" @@ -57,3 +58,15 @@ func CandidateKey(group string) (string, error) { } return catalog.CandidatesByGroupKey(keys.EscapeName(group)), nil } + +// The states of the vault seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4). +const ( + // Signers holds the vault's public signing halves; NodeLogins each node's logins, ``. + Signers = vaultHolder + "_" + vault.StateSigners + NodeLogins = vaultHolder + "_" + vault.StateNodeLogins + // SignersRead and NodeLoginsRead are the states as a manifest's reads names them. + SignersRead = vaultHolder + "." + vault.StateSigners + NodeLoginsRead = vaultHolder + "." + vault.StateNodeLogins + + vaultHolder = "mesh-vault" +) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 9e7b302..e3971b8 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -36,6 +36,7 @@ import ( "errors" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" @@ -51,7 +52,6 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/takein" @@ -93,11 +93,11 @@ type Reader interface { Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) } -// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket, -// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the +// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (buckets.Signers, +// key meshvault.SignersByAuthorityKey(); ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the // controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before. type SignersReader interface { - // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the + // VaultAuthority is the vault's authority certificate as PEM, checked (meshvaultclient.DecodeSignerAuthority); empty when the // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the // state yet composes as before rather than not at all. VaultAuthority(ctx context.Context) string @@ -345,7 +345,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { // against the bundle. The controller's stays while a certificate it issued is in a node's record. if sr, ok := r.(SignersReader); ok { if vault := sr.VaultAuthority(ctx); vault != "" { - in.Authority = delivery.AuthorityBundle(in.Authority, vault) + in.Authority = meshvaultclient.AuthorityBundle(in.Authority, vault) } } named := func(field *map[string]string, module, why string) { diff --git a/internal/identity/token.go b/internal/identity/token.go index f024bb8..efec00f 100644 --- a/internal/identity/token.go +++ b/internal/identity/token.go @@ -1,15 +1,17 @@ package identity -import "git.novox.be/novox/mesh-sdk/go/delivery" +import ( + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient" +) // Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the broker is, what certificate to // expect there, whose signature to believe afterwards, and a one-time right to join. Its shape and its reading are mesh-sdk's -// (delivery.Token), which the vault that makes it, mesh-cli that opens it and this engine that parses it share (novox/hq ADR +// (meshnodeenrollerclient.Token), which the vault that makes it, mesh-cli that opens it and this engine that parses it share (novox/hq ADR // 0587 §3, issue 774); these are the names this package has always had for them. -type Token = delivery.Token +type Token = meshnodeenrollerclient.Token -// TokenTunnel is the joining machine's side of its first tunnel (delivery.TokenTunnel). -type TokenTunnel = delivery.TokenTunnel +// TokenTunnel is the joining machine's side of its first tunnel (meshnodeenrollerclient.TokenTunnel). +type TokenTunnel = meshnodeenrollerclient.TokenTunnel -// ParseToken reads a token a person pasted (delivery.ParseToken). -func ParseToken(encoded string) (Token, error) { return delivery.ParseToken(encoded) } +// ParseToken reads a token a person pasted (meshnodeenrollerclient.ParseToken). +func ParseToken(encoded string) (Token, error) { return meshnodeenrollerclient.ParseToken(encoded) } diff --git a/internal/link/enrol_seat.go b/internal/link/enrol_seat.go index d6eb34f..43b8556 100644 --- a/internal/link/enrol_seat.go +++ b/internal/link/enrol_seat.go @@ -7,12 +7,14 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "github.com/novox/mesh-host/internal/buckets" "strings" "time" "github.com/nats-io/nats.go" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // Joining a mesh through the seat mesh-enrolment (novox/hq ADR 0587 §6, issue 774): the node-engine dials the bus as enrol. @@ -40,7 +42,7 @@ var ErrNoSeat = errors.New("the seat mesh-enrolment is not served to this machin // VerifyRedeemAnswer holds an answer to what it must be before anything is kept (ADR 0587 §6): it names the node asked for, // carries the signing key the token carries, and is signed by that key under the context of an enrolment's answer. An answer // signed by another key is refused, and so is one that names another signer. -func VerifyRedeemAnswer(a delivery.RedeemAnswer, node string, signer ed25519.PublicKey) error { +func VerifyRedeemAnswer(a meshnodeenrollerclient.RedeemAnswer, node string, signer ed25519.PublicKey) error { if a.Node != node { return fmt.Errorf("the answer is for %q, and this machine asked as %q", a.Node, node) } @@ -51,11 +53,11 @@ func VerifyRedeemAnswer(a delivery.RedeemAnswer, node string, signer ed25519.Pub if err != nil || len(sig) == 0 { return errors.New("the answer is not signed") } - kindContext, err := delivery.SignContext(delivery.KindEnrolment) + kindContext, err := meshvaultclient.SignContext(meshvaultclient.KindEnrolment) if err != nil { return err } - if _, err := delivery.VerifyBy([]delivery.Signer{{Name: "the token's", Key: signer}}, kindContext, a.SignedBytes(), sig); err != nil { + if _, err := meshvaultclient.VerifyBy([]meshvaultclient.Signer{{Name: "the token's", Key: signer}}, kindContext, a.SignedBytes(), sig); err != nil { return fmt.Errorf("the answer is not signed by the key the token carries: %w", err) } if a.Login == "" || a.Account == "" { @@ -83,52 +85,52 @@ func seatAnswer(reply []byte) (json.RawMessage, error) { // asks again with the same keys while the holder says to (a stopped redeem finishes; the bus node loads the account), , for // EnrolPatience. An answer is returned as the holder gave it: the caller // verifies it (VerifyRedeemAnswer) before it keeps anything. -func Redeem(ctx context.Context, to Approach, node, secret string, req delivery.Redeem, timeout time.Duration) (delivery.RedeemAnswer, error) { +func Redeem(ctx context.Context, to Approach, node, secret string, req meshnodeenrollerclient.Redeem, timeout time.Duration) (meshnodeenrollerclient.RedeemAnswer, error) { if to.Transport != OnNATS { - return delivery.RedeemAnswer{}, fmt.Errorf("this token is for the %q bus, and the mesh's bus is %s", to.Transport, OnNATS) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("this token is for the %q bus, and the mesh's bus is %s", to.Transport, OnNATS) } config, err := PinnedConfig(to.Fingerprint) if err != nil { - return delivery.RedeemAnswer{}, err + return meshnodeenrollerclient.RedeemAnswer{}, err } - user := delivery.EnrolmentUser(node) + user := meshnodeenrollerclient.User(node) conn, err := nats.Connect(natsURL(to.Address), nats.Secure(config), nats.CustomInboxPrefix("_INBOX."+user), nats.UserInfo(user, secret), nats.Name("mesh-host/enrol/"+node), nats.Timeout(timeout), nats.NoReconnect()) if err != nil { if errors.Is(err, ErrWrongCertificate) { - return delivery.RedeemAnswer{}, err + return meshnodeenrollerclient.RedeemAnswer{}, err } - return delivery.RedeemAnswer{}, fmt.Errorf("cannot reach the bus at %s as %s: %w", to.Address, user, err) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("cannot reach the bus at %s as %s: %w", to.Address, user, err) } defer conn.Close() body, err := json.Marshal(req) if err != nil { - return delivery.RedeemAnswer{}, err + return meshnodeenrollerclient.RedeemAnswer{}, err } began := time.Now() for { msg, err := conn.RequestWithContext(ctx, CalledSeatSubject(EnrolmentSeat, EnrolmentRedeem, user), body) switch { case errors.Is(err, nats.ErrNoResponders) || (err != nil && strings.Contains(err.Error(), "permissions violation")): - return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrNoSeat, err) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrNoSeat, err) case err != nil: - return delivery.RedeemAnswer{}, fmt.Errorf("the seat did not answer redeem: %w", err) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the seat did not answer redeem: %w", err) } result, aerr := seatAnswer(msg.Data) if aerr != nil { if strings.Contains(aerr.Error(), "call redeem again with the same keys") && time.Since(began) < EnrolPatience { select { case <-ctx.Done(): - return delivery.RedeemAnswer{}, ctx.Err() + return meshnodeenrollerclient.RedeemAnswer{}, ctx.Err() case <-time.After(AskAgainAfter): continue } } - return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrRefused, aerr) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrRefused, aerr) } - var answer delivery.RedeemAnswer + var answer meshnodeenrollerclient.RedeemAnswer if err := json.Unmarshal(result, &answer); err != nil { - return delivery.RedeemAnswer{}, fmt.Errorf("the seat's answer to redeem is not one: %w", err) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the seat's answer to redeem is not one: %w", err) } return answer, nil } @@ -137,7 +139,7 @@ func Redeem(ctx context.Context, to Approach, node, secret string, req delivery. // Confirming is this machine dialled as its own login after it redeemed: it reads its key of node-logins and calls confirm. type Confirming interface { // Login is this node's key of the vault's node-logins. - Login(ctx context.Context) (delivery.NodeLogin, error) + Login(ctx context.Context) (meshvault.NodeLoginsByNodeValue, error) // Confirm calls the seat's confirm; the runtime's proof of the caller as node. is the confirmation (ADR 0583 §3). Confirm(ctx context.Context) error Close() @@ -149,29 +151,29 @@ type Joining struct { Signer ed25519.PublicKey // Redeem calls the seat; Open opens the account sealed to this node's sealing key; Keep writes the membership, the login // and the keys down; Dial dials the bus as login and answers it ready to read node-logins and confirm. - Redeem func(ctx context.Context) (delivery.RedeemAnswer, error) - Open func(a delivery.RedeemAnswer) (string, error) - Keep func(a delivery.RedeemAnswer, password string) error + Redeem func(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) + Open func(a meshnodeenrollerclient.RedeemAnswer) (string, error) + Keep func(a meshnodeenrollerclient.RedeemAnswer, password string) error Dial func(ctx context.Context, login, password string) (Confirming, error) } // Run joins: the answer is verified before anything is kept (an answer not signed by the key the token carries keeps nothing), // kept, and then the machine dials as the login its key of node-logins names and confirms. Nothing is applied here: the engine // applies once its placement names a module. -func (j Joining) Run(ctx context.Context) (delivery.RedeemAnswer, error) { +func (j Joining) Run(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) { answer, err := j.Redeem(ctx) if err != nil { - return delivery.RedeemAnswer{}, err + return meshnodeenrollerclient.RedeemAnswer{}, err } if err := VerifyRedeemAnswer(answer, j.Node, j.Signer); err != nil { - return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v; nothing was kept", ErrRefused, err) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("%w: %v; nothing was kept", ErrRefused, err) } password, err := j.Open(answer) if err != nil { - return delivery.RedeemAnswer{}, fmt.Errorf("the account in the answer cannot be opened: %w; nothing was kept", err) + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the account in the answer cannot be opened: %w; nothing was kept", err) } if err := j.Keep(answer, password); err != nil { - return delivery.RedeemAnswer{}, fmt.Errorf("the mesh accepted this node as %q and what it was given could not be kept: %w\n"+ + return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the mesh accepted this node as %q and what it was given could not be kept: %w\n"+ "That token is spent, so getting back needs a new one", answer.Node, err) } c, err := j.Dial(ctx, answer.Login, password) @@ -213,20 +215,20 @@ func DialConfirming(ctx context.Context, m Membership, login, password string, t return &confirmingNats{conn: conn, node: m.Node, user: login}, nil } -func (c *confirmingNats) Login(ctx context.Context) (delivery.NodeLogin, error) { +func (c *confirmingNats) Login(ctx context.Context) (meshvault.NodeLoginsByNodeValue, error) { js, err := c.conn.JetStream() if err != nil { - return delivery.NodeLogin{}, err + return meshvault.NodeLoginsByNodeValue{}, err } - kv, err := js.KeyValue(delivery.NodeLoginsBucket) + kv, err := js.KeyValue(buckets.NodeLogins) if err != nil { - return delivery.NodeLogin{}, err + return meshvault.NodeLoginsByNodeValue{}, err } e, err := kv.Get(c.node) if err != nil { - return delivery.NodeLogin{}, err + return meshvault.NodeLoginsByNodeValue{}, err } - return delivery.DecodeNodeLogin(e.Value()) + return meshvaultclient.DecodeNodeLogin(e.Value()) } func (c *confirmingNats) Confirm(ctx context.Context) error { diff --git a/internal/link/enrol_seat_test.go b/internal/link/enrol_seat_test.go index b1393a9..b594d8d 100644 --- a/internal/link/enrol_seat_test.go +++ b/internal/link/enrol_seat_test.go @@ -6,33 +6,34 @@ import ( "crypto/ed25519" "encoding/base64" "errors" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "slices" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // signedBy is an answer for ace, signed under the enrolment context by the key seeded, naming that key as its signer. -func signedBy(t *testing.T, seed byte) (delivery.RedeemAnswer, ed25519.PublicKey) { +func signedBy(t *testing.T, seed byte) (meshnodeenrollerclient.RedeemAnswer, ed25519.PublicKey) { t.Helper() priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize)) pub := priv.Public().(ed25519.PublicKey) - a := delivery.RedeemAnswer{Node: "ace", Login: "node.ace", Account: `{"sealed":"box"}`, Certificate: "PEM", Signer: base64.StdEncoding.EncodeToString(pub)} - ctx, err := delivery.SignContext(delivery.KindEnrolment) + a := meshnodeenrollerclient.RedeemAnswer{Node: "ace", Login: "node.ace", Account: `{"sealed":"box"}`, Certificate: "PEM", Signer: base64.StdEncoding.EncodeToString(pub)} + ctx, err := meshvaultclient.SignContext(meshvaultclient.KindEnrolment) if err != nil { t.Fatal(err) } - a.Signature = base64.StdEncoding.EncodeToString(delivery.SignMessage(priv, ctx, a.SignedBytes())) + a.Signature = base64.StdEncoding.EncodeToString(meshvaultclient.SignMessage(priv, ctx, a.SignedBytes())) return a, pub } type standInConfirming struct { steps *[]string - nl delivery.NodeLogin + nl meshvault.NodeLoginsByNodeValue } -func (c standInConfirming) Login(context.Context) (delivery.NodeLogin, error) { +func (c standInConfirming) Login(context.Context) (meshvault.NodeLoginsByNodeValue, error) { *c.steps = append(*c.steps, "read node-logins") return c.nl, nil } @@ -52,18 +53,21 @@ func TestTheEngineKeepsOnlyAnAnswerSignedByTheKeyItsTokenCarries(t *testing.T) { badNode := good badNode.Node = "g14" for name, c := range map[string]struct { - answer delivery.RedeemAnswer + answer meshnodeenrollerclient.RedeemAnswer want string }{ "signed by another key": {forged, "signing key other than the one the token carries"}, "another node": {badNode, `is for "g14"`}, - "altered after signing": {func() delivery.RedeemAnswer { a := good; a.Account = "other"; return a }(), "not signed by the key the token carries"}, + "altered after signing": {func() meshnodeenrollerclient.RedeemAnswer { a := good; a.Account = "other"; return a }(), "not signed by the key the token carries"}, } { var steps []string j := Joining{Node: "ace", Signer: pub, - Redeem: func(context.Context) (delivery.RedeemAnswer, error) { return c.answer, nil }, - Open: func(delivery.RedeemAnswer) (string, error) { steps = append(steps, "open"); return "pw", nil }, - Keep: func(delivery.RedeemAnswer, string) error { steps = append(steps, "keep"); return nil }, + Redeem: func(context.Context) (meshnodeenrollerclient.RedeemAnswer, error) { return c.answer, nil }, + Open: func(meshnodeenrollerclient.RedeemAnswer) (string, error) { + steps = append(steps, "open") + return "pw", nil + }, + Keep: func(meshnodeenrollerclient.RedeemAnswer, string) error { steps = append(steps, "keep"); return nil }, Dial: func(context.Context, string, string) (Confirming, error) { steps = append(steps, "dial") return nil, errors.New("must not dial") @@ -75,18 +79,21 @@ func TestTheEngineKeepsOnlyAnAnswerSignedByTheKeyItsTokenCarries(t *testing.T) { var steps []string login := "node.ace" j := Joining{Node: "ace", Signer: pub, - Redeem: func(context.Context) (delivery.RedeemAnswer, error) { + Redeem: func(context.Context) (meshnodeenrollerclient.RedeemAnswer, error) { steps = append(steps, "redeem") return good, nil }, - Open: func(delivery.RedeemAnswer) (string, error) { steps = append(steps, "open"); return "pw", nil }, - Keep: func(a delivery.RedeemAnswer, pw string) error { + Open: func(meshnodeenrollerclient.RedeemAnswer) (string, error) { + steps = append(steps, "open") + return "pw", nil + }, + Keep: func(a meshnodeenrollerclient.RedeemAnswer, pw string) error { steps = append(steps, "keep "+a.Login+" "+pw) return nil }, Dial: func(_ context.Context, l, pw string) (Confirming, error) { steps = append(steps, "dial "+l+" "+pw) - return standInConfirming{steps: &steps, nl: delivery.NodeLogin{Node: "ace", Login: login}}, nil + return standInConfirming{steps: &steps, nl: meshvault.NodeLoginsByNodeValue{Node: "ace", Login: login}}, nil }} if _, err := j.Run(ctx); err != nil { t.Fatal(err) diff --git a/internal/link/files.go b/internal/link/files.go index b4a251c..44d02dd 100644 --- a/internal/link/files.go +++ b/internal/link/files.go @@ -7,10 +7,10 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" "time" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" ) @@ -114,7 +114,7 @@ func (b OverNATS) GrantsObject(ctx context.Context, name string, limit int64) ([ } js = bounded } - return objectByName(ctx, js, delivery.GrantsUserListObjects, delivery.GrantsUserListObjectStream, name, limit) + return objectByName(ctx, js, grantsstate.GrantsUserListObjects, grantsstate.GrantsUserListObjectStream, name, limit) } // objectByName is one object of bucket (its stream stream) by its name, no larger than limit, verified against its digest. diff --git a/internal/link/logins.go b/internal/link/logins.go index 1ee7fa7..a9df4a4 100644 --- a/internal/link/logins.go +++ b/internal/link/logins.go @@ -2,12 +2,12 @@ package link import ( "errors" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "strings" "sync" "github.com/nats-io/nats.go" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // Logins is which of its two logins this node-engine dials the bus as, and the password of each it holds (novox/hq ADR 0583 §1, @@ -42,12 +42,12 @@ func NewLogins(node, first string, saved LoginsState, save func(LoginsState) err l.values[login] = password } if first != "" { - if _, ok := l.values[delivery.FirstLogin(node)]; !ok { - l.values[delivery.FirstLogin(node)] = first + if _, ok := l.values[meshvaultclient.FirstLogin(node)]; !ok { + l.values[meshvaultclient.FirstLogin(node)] = first } } if l.current == "" { - l.current = delivery.FirstLogin(node) + l.current = meshvaultclient.FirstLogin(node) } return l } @@ -71,7 +71,7 @@ func (l *Logins) Value(login string) (string, bool) { func (l *Logins) Hold(login, password string) error { l.mu.Lock() defer l.mu.Unlock() - if n, ok := delivery.LoginNode(login); !ok || n != l.node { + if n, ok := meshvaultclient.LoginNode(login); !ok || n != l.node { return &loginError{login: login, node: l.node} } l.values[login] = password @@ -164,7 +164,7 @@ func (m Membership) User() string { // Heard takes this node's key of the vault's node-logins as read (a key read, or the empty value of a key gone): the login // in use becomes the one it names when its password is held. A key naming a login whose password is not held changes // nothing and says so; no key leaves the login as it is. -func (l *Logins) Heard(nl delivery.NodeLogin) (said string) { +func (l *Logins) Heard(nl meshvault.NodeLoginsByNodeValue) (said string) { if nl.Node != l.node || nl.Login == "" { return "" } @@ -182,7 +182,7 @@ func (l *Logins) Heard(nl delivery.NodeLogin) (said string) { } // rotatingToUs says the key's rotating login is the one in use already: the engine switched ahead of the vault's own state. -func rotatingToUs(nl delivery.NodeLogin, current string) bool { +func rotatingToUs(nl meshvault.NodeLoginsByNodeValue, current string) bool { return nl.Rotating != "" && nl.Rotating == current } diff --git a/internal/link/logins_test.go b/internal/link/logins_test.go index 38ffd41..3af430e 100644 --- a/internal/link/logins_test.go +++ b/internal/link/logins_test.go @@ -1,10 +1,9 @@ package link import ( + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // 774/S61/AC1 and AC2 (ADR 0583 §1): with no node-logins key, or one that names the first login, the engine dials as node. @@ -20,10 +19,10 @@ func TestTheLoginDialledIsTheOneNodeLoginsNames(t *testing.T) { if u, p := (Membership{Node: "ace", Password: "plain"}).Credential(); u != "node.ace" || p != "plain" { t.Fatalf("no logins at all: %s %s", u, p) } - if said := l.Heard(delivery.NodeLogin{Node: "ace", Login: "node.ace"}); said != "" || m.User() != "node.ace" { + if said := l.Heard(meshvault.NodeLoginsByNodeValue{Node: "ace", Login: "node.ace"}); said != "" || m.User() != "node.ace" { t.Errorf("the first login named: %q, %s", said, m.User()) } - if said := l.Heard(delivery.NodeLogin{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "cannot dial as it") || m.User() != "node.ace" { + if said := l.Heard(meshvault.NodeLoginsByNodeValue{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "cannot dial as it") || m.User() != "node.ace" { t.Errorf("a login held no value for: %q, %s", said, m.User()) } if err := l.Hold("node.ace--b", "second-secret"); err != nil { @@ -32,7 +31,7 @@ func TestTheLoginDialledIsTheOneNodeLoginsNames(t *testing.T) { if err := l.Hold("node.g14", "x"); err == nil { t.Error("another node's login was kept") } - if said := l.Heard(delivery.NodeLogin{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "dialled as it") { + if said := l.Heard(meshvault.NodeLoginsByNodeValue{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "dialled as it") { t.Errorf("said %q", said) } if u, p := m.Credential(); u != "node.ace--b" || p != "second-secret" || saved.Current != "node.ace--b" { diff --git a/internal/link/rotate.go b/internal/link/rotate.go index e0474e9..f9a1a78 100644 --- a/internal/link/rotate.go +++ b/internal/link/rotate.go @@ -4,10 +4,11 @@ import ( "context" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "sync" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // Rotator is the engine's side of a node's account rotation (novox/hq ADR 0583 §3-§6, issue 774). The vault begins a rotation @@ -49,7 +50,7 @@ func (r *Rotator) say(f string, a ...any) { } // Heard takes this node's key of node-logins as read. -func (r *Rotator) Heard(ctx context.Context, nl delivery.NodeLogin) { +func (r *Rotator) Heard(ctx context.Context, nl meshvault.NodeLoginsByNodeValue) { r.mu.Lock() defer r.mu.Unlock() if r.fallen != nil && nl.Since.String() != r.fellBackSince { @@ -61,7 +62,7 @@ func (r *Rotator) Heard(ctx context.Context, nl delivery.NodeLogin) { // Done (the switch happened and the old login is retired), or no rotation at all: the other login's value goes (§4), // only now that the vault says so. r.old, r.failures = "", 0 - if other := delivery.OtherLogin(nl.Node, cur); other != "" { + if other := meshvaultclient.OtherLogin(nl.Node, cur); other != "" { if _, held := r.Logins.Value(other); held { if err := r.Logins.Drop(other); err != nil { r.say("the old login %s's value is not dropped: %v", other, err) @@ -86,18 +87,18 @@ func (r *Rotator) Heard(ctx context.Context, nl delivery.NodeLogin) { if err := r.Logins.Drop(cur); err != nil { r.say("the abandoned login %s's value is not dropped: %v", cur, err) } - case nl.Login == cur && nl.Stage != delivery.RotationBegun: + case nl.Login == cur && nl.Stage != meshvaultclient.RotationBegun: if r.fellBackSince != "" && r.fellBackSince == nl.Since.String() { return // fallen back for this rotation: not tried again until the vault begins another } r.switchTo(ctx, nl) - case nl.Rotating == cur && nl.Stage != delivery.RotationConfirmed: + case nl.Rotating == cur && nl.Stage != meshvaultclient.RotationConfirmed: r.confirm(ctx, nl.Rotating) } } // switchTo fetches the new value, test-dials it and, only if it authenticates, switches and confirms (§3). -func (r *Rotator) switchTo(ctx context.Context, nl delivery.NodeLogin) { +func (r *Rotator) switchTo(ctx context.Context, nl meshvault.NodeLoginsByNodeValue) { next := nl.Rotating password, held := r.Logins.Value(next) if !held { @@ -158,7 +159,7 @@ func (r *Rotator) AuthFailed(login string) { return } text := fmt.Sprintf("%s failed to authenticate %d times before the vault said rotation-done: this node dials as %s again", login, r.failures, r.old) - r.say("%s: %s", delivery.NodeAccountFallbackFinding, text) + r.say("%s: %s", NodeAccountFallbackFinding, text) now := time.Now if r.Now != nil { now = r.Now @@ -201,3 +202,7 @@ func (r *Rotator) Watch() { } } } + +// NodeAccountFallbackFinding is what the node-engine says when it fell back to its old login after repeated +// authentication failures with the new one, before the rotation is done; the watchman raises it (ADR 0550). +const NodeAccountFallbackFinding = "node-account-fallback" diff --git a/internal/link/rotate_integration_test.go b/internal/link/rotate_integration_test.go index a3d42ea..8636495 100644 --- a/internal/link/rotate_integration_test.go +++ b/internal/link/rotate_integration_test.go @@ -5,14 +5,14 @@ package link import ( "context" "errors" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "strings" "testing" "time" natsserver "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // 774/S65/AC1 (ADR 0583 §2-§6): the engine's rotation against a real bus whose list of users a stand-in vault reloads step by @@ -114,19 +114,19 @@ func TestARotationAgainstARealBusWhoseListIsReloadedStepByStep(t *testing.T) { } ctx := context.Background() since := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC) - key := func(login, rotating, stage string) delivery.NodeLogin { - return delivery.NodeLogin{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: since} + key := func(login, rotating, stage string) meshvault.NodeLoginsByNodeValue { + return meshvault.NodeLoginsByNodeValue{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: since} } // A whole rotation: begun, the list holds both logins, ready, the switch and the confirmation, the old login retired, done. srv, reload := start(t) r, l, confirmed := build(t, srv) oneAuthenticates(t, srv, "before the rotation") - r.Heard(ctx, key(first, second, delivery.RotationBegun)) + r.Heard(ctx, key(first, second, meshvaultclient.RotationBegun)) oneAuthenticates(t, srv, "begun") reload(first, second) oneAuthenticates(t, srv, "the list holds both") - r.Heard(ctx, key(first, second, delivery.RotationReady)) + r.Heard(ctx, key(first, second, meshvaultclient.RotationReady)) if cur, _ := l.Current(); cur != second { t.Fatalf("the engine did not switch once the list held both logins: %s", cur) } @@ -149,7 +149,7 @@ func TestARotationAgainstARealBusWhoseListIsReloadedStepByStep(t *testing.T) { // With the list never loaded the engine never switches: the test-dial is refused. srv, _ = start(t) r, l, _ = build(t, srv) - r.Heard(ctx, key(first, second, delivery.RotationReady)) + r.Heard(ctx, key(first, second, meshvaultclient.RotationReady)) if cur, _ := l.Current(); cur != first || !authenticates(srv, first) { t.Errorf("the list was never loaded and the engine is on %s", cur) } @@ -158,7 +158,7 @@ func TestARotationAgainstARealBusWhoseListIsReloadedStepByStep(t *testing.T) { srv, reload = start(t) r, l, _ = build(t, srv) reload(first, second) - r.Heard(ctx, key(first, second, delivery.RotationReady)) + r.Heard(ctx, key(first, second, meshvaultclient.RotationReady)) reload(first) r.Heard(ctx, key(first, "", "")) if cur, _ := l.Current(); cur != first || !authenticates(srv, first) { diff --git a/internal/link/rotate_test.go b/internal/link/rotate_test.go index 0b3c1fc..ceb0191 100644 --- a/internal/link/rotate_test.go +++ b/internal/link/rotate_test.go @@ -5,12 +5,12 @@ import ( "errors" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "slices" "strings" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // rotRig is a Rotator over stand-ins: what the vault hands, what a test-dial answers, and what it was asked, in order. @@ -53,8 +53,8 @@ func newRotRig() *rotRig { return g } -func key(login, rotating, stage string) delivery.NodeLogin { - return delivery.NodeLogin{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)} +func key(login, rotating, stage string) meshvault.NodeLoginsByNodeValue { + return meshvault.NodeLoginsByNodeValue{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)} } // 774/S62/AC1 and AC2 (ADR 0583 §3): at ready the engine fetches the new value and test-dials it; one that does not @@ -63,18 +63,18 @@ func key(login, rotating, stage string) delivery.NodeLogin { func TestTheEngineSwitchesOnlyAfterATestDialAndConfirmsAsTheNewLogin(t *testing.T) { g := newRotRig() ctx := context.Background() - g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationBegun)) + g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationBegun)) if len(g.steps) != 0 { t.Fatalf("before ready: %v", g.steps) } g.dialErr = errors.New("authorization violation") - g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady)) + g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady)) if cur, _ := g.logins.Current(); cur != "node.ace" || !strings.Contains(strings.Join(g.said, "|"), "stays on node.ace") || slices.Contains(g.steps, "confirm as node.ace--b") { t.Fatalf("a test-dial that failed: %s, said %v, steps %v", cur, g.said, g.steps) } g.dialErr = nil - g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady)) + g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady)) cur, pw := g.logins.Current() if cur != "node.ace--b" || pw != "secret-of-node.ace--b" { t.Fatalf("after a good test-dial: %s %s", cur, pw) @@ -99,9 +99,9 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t * ctx := context.Background() for _, stage := range []string{"before confirming", "after confirming"} { g := newRotRig() - g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady)) + g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady)) if stage == "after confirming" { - g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationConfirmed)) + g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationConfirmed)) } if _, held := g.logins.Value("node.ace"); !held { t.Fatalf("%s: the old value was dropped before rotation-done", stage) @@ -121,7 +121,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t * } } g := newRotRig() - g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady)) + g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady)) g.r.Heard(ctx, key("node.ace--b", "", "")) // rotation-done: the vault retired the old login if _, held := g.logins.Value("node.ace"); held { t.Error("the old value is kept after rotation-done") @@ -131,7 +131,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t * } // Fallback: two failures with the new value (the bound) before rotation-done. f := newRotRig() - f.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady)) + f.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady)) f.logins.dialed("node.ace--b", errors.New("nats: authorization violation")) if cur, _ := f.logins.Current(); cur != "node.ace--b" { t.Fatalf("fell back after one failure: %s", cur) @@ -146,7 +146,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t * t.Errorf("the fall back is no health report's: %v", err) } steps := len(f.steps) - f.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady)) + f.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady)) if len(f.steps) != steps { t.Errorf("the rotation fallen back from was tried again: %v", f.steps[steps:]) } diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index cee73ed..3ba5644 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -21,6 +21,7 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" @@ -41,7 +42,6 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/resolution" @@ -1360,7 +1360,7 @@ func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p meshmoduleas return } trusted := []string{rec.Authority, r.VaultAuthority(ctx)} - if rec.Certificate != "" && delivery.CheckNodeCertificate(rec.Certificate, trusted, overlay.InternalName(c.env.Node), + if rec.Certificate != "" && meshvaultclient.CheckNodeCertificate(rec.Certificate, trusted, overlay.InternalName(c.env.Node), ed25519.PublicKey(key), time.Now()) == nil { return } diff --git a/internal/userlist/userlist.go b/internal/userlist/userlist.go index de4daa9..fd75b1f 100644 --- a/internal/userlist/userlist.go +++ b/internal/userlist/userlist.go @@ -32,16 +32,15 @@ import ( "encoding/json" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/novox/mesh-host/internal/agent" engcomp "github.com/novox/mesh-host/internal/compositions" "regexp" "sort" "strings" "sync" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/seal" - "github.com/novox/mesh-host/internal/agent" ) // ResourceID is the id the composer gives the bus's user list (mesh-sdk catalogue.BusUsersID); in a composition it may @@ -66,7 +65,7 @@ type Env struct { Write func(ctx context.Context, module string, resource json.RawMessage, body []byte) (digest string, changed bool, err error) // Report says what was written, in the module's have. Report func(ctx context.Context, module string, w Written) - // Grants watches the grants part's state (its key delivery.GrantsUserListKey); nil writes nothing. + // Grants watches the grants part's state (its key grantsstate.GrantsUserListKey); nil writes nothing. Grants agent.Watcher // Fetch reads an object of the grants part's bucket by its name; Open opens a box sealed to this node's key; Key is // this node's sealing key's public half (SPKI PEM), said in the have so the grants part seals to it ("" unknown). @@ -103,7 +102,7 @@ type Follower struct { mod string said map[string]bool // grants is the grants part's key as last heard (nil when none). - grants *delivery.GrantsUserList + grants *grantsstate.GrantsUserList grantsRev uint64 // unwatched is why the grants part's key is not watched now; empty while it is, or where it need not be. unwatched string @@ -124,7 +123,7 @@ func New(env Env) *Follower { said: map[string]bool{}} if env.Grants != nil && env.Kept != nil { if raw, rev := env.Kept(); raw != nil { - if g, err := delivery.DecodeGrantsUserList(raw); err == nil { + if g, err := grantsstate.DecodeGrantsUserList(raw); err == nil { f.grants, f.grantsRev = &g, rev } } @@ -180,7 +179,7 @@ func (f *Follower) Run(ctx context.Context) error { return } opened = true - ch, err := f.env.Grants.Watch(ctx, delivery.GrantsUserListKey) + ch, err := f.env.Grants.Watch(ctx, grantsstate.GrantsUserListKey) if err != nil { failed(err.Error()) return @@ -268,7 +267,7 @@ func (f *Follower) takeGrants(u agent.Update) { f.sayOnceLocked("the grants part's user-list key is deleted, so the list loaded is kept") return } - g, err := delivery.DecodeGrantsUserList(u.Value) + g, err := grantsstate.DecodeGrantsUserList(u.Value) if err != nil { f.sayOnceLocked("the grants part's user-list key cannot be read, so the list loaded is kept: " + err.Error()) return @@ -312,7 +311,7 @@ func reportable(prev, w Written) bool { // says why in the have. func (f *Follower) settleGrants(ctx context.Context, again bool) { f.mu.Lock() - var g delivery.GrantsUserList + var g grantsstate.GrantsUserList heard := f.grants != nil if heard { g = *f.grants @@ -343,8 +342,8 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) { } f.mu.Unlock() - w := Written{Source: delivery.GrantsUserListSource, Revision: int64(rev), Wanted: g.Digest, At: f.env.Now().UTC(), - Follows: delivery.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()} + w := Written{Source: grantsstate.GrantsUserListSource, Revision: int64(rev), Wanted: g.Digest, At: f.env.Now().UTC(), + Follows: grantsstate.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()} var err error var changed bool resource, rerr := mode0600(file.resource) @@ -357,14 +356,14 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) { err = fmt.Errorf("the grants part's key names no list yet; the list on the machine is kept") case f.env.Fetch == nil || f.env.Open == nil: err = fmt.Errorf("this engine cannot read or open the grants part's list") - case g.SealedTo != "" && w.Key != "" && g.SealedTo != delivery.SealingKeyID(w.Key): + case g.SealedTo != "" && w.Key != "" && g.SealedTo != grantsstate.SealingKeyID(w.Key): err = fmt.Errorf("the grants part's list is sealed to another key than this node's (%s); the list on the machine is kept until it seals to this one", g.SealedTo) } var body []byte if err == nil { var object []byte if object, err = f.env.Fetch(ctx, g.Object); err == nil { - body, err = delivery.OpenUserList(object, g.Digest, f.env.Open) + body, err = grantsstate.OpenUserList(object, g.Digest, f.env.Open) } if err != nil { err = fmt.Errorf("the grants part's list %s could not be had: %w", short(g.Digest), err) @@ -395,7 +394,7 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) { f.mod, f.state = file.module, w f.written = err == nil f.mu.Unlock() - at := fmt.Sprintf("%s@%d", delivery.GrantsUserListSource, rev) + at := fmt.Sprintf("%s@%d", grantsstate.GrantsUserListSource, rev) if err != nil { f.sayOnce(fmt.Sprintf("the bus's user list of %s could not be written: %v", at, err)) } else if changed { diff --git a/internal/userlist/userlist_test.go b/internal/userlist/userlist_test.go index 3fdecc0..f07ed61 100644 --- a/internal/userlist/userlist_test.go +++ b/internal/userlist/userlist_test.go @@ -5,15 +5,14 @@ import ( "encoding/json" "errors" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + "git.novox.be/novox/mesh-sdk/go/seal" + "github.com/novox/mesh-host/internal/agent" engcomp "github.com/novox/mesh-host/internal/compositions" "strings" "sync" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/seal" - "github.com/novox/mesh-host/internal/agent" ) const node = "novox" @@ -126,50 +125,50 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { // this node runs declares; the list a composition carries is never written, whatever becomes of the grants key. // B17: the file's place is read from what runs, as the agent holds it, never from a composition bucket. {name: "the grants part's list is written into the file the bus module it runs declares", - before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three}}, - wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: grantsstate.FollowGrants, content: three}}, + wantWrites: []string{three}, wantUsers: 4, wantSource: grantsstate.GrantsUserListSource, wantFollows: grantsstate.FollowGrants}, {name: "a bus module it runs by digest still names the file", - before: []put{{module: "nats", fp: fpOf("a"), content: two, byDigest: true}}, grants: []gput{{follow: delivery.FollowGrants, content: three}}, - wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + before: []put{{module: "nats", fp: fpOf("a"), content: two, byDigest: true}}, grants: []gput{{follow: grantsstate.FollowGrants, content: three}}, + wantWrites: []string{three}, wantUsers: 4, wantSource: grantsstate.GrantsUserListSource, wantFollows: grantsstate.FollowGrants}, {name: "a newer composition's list is not written", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, after: []put{{module: "nats", fp: fpOf("b"), content: five}}, - grants: []gput{{follow: delivery.FollowGrants, content: three}}, - wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + grants: []gput{{follow: grantsstate.FollowGrants, content: three}}, + wantWrites: []string{three}, wantUsers: 4, wantSource: grantsstate.GrantsUserListSource, wantFollows: grantsstate.FollowGrants}, {name: "a composition without the list is ignored", before: []put{{module: "web", fp: fpOf("a"), content: ""}}, - grants: []gput{{follow: delivery.FollowGrants, content: three}}}, + grants: []gput{{follow: grantsstate.FollowGrants, content: three}}}, {name: "another node's composition declares no file", - before: []put{{module: "nats", fp: fpOf("a"), content: two, asNode: "ace"}}, grants: []gput{{follow: delivery.FollowGrants, content: three}}}, + before: []put{{module: "nats", fp: fpOf("a"), content: two, asNode: "ace"}}, grants: []gput{{follow: grantsstate.FollowGrants, content: three}}}, {name: "an unreadable grants key with none heard before writes nothing, never the composition's list", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{unreadable: true}}, - onDisk: two, wantErr: "key is not heard", wantRefused: true, wantSource: delivery.GrantsUserListSource}, + onDisk: two, wantErr: "key is not heard", wantRefused: true, wantSource: grantsstate.GrantsUserListSource}, {name: "a refused watch with no key kept writes nothing and says it is not watched", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grantsRefused: true, wantUnwatched: true, - onDisk: two, wantErr: "key is not heard", wantRefused: true, wantSource: delivery.GrantsUserListSource}, + onDisk: two, wantErr: "key is not heard", wantRefused: true, wantSource: grantsstate.GrantsUserListSource}, {name: "a refused watch keeps the key last heard", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grantsRefused: true, wantUnwatched: true, - kept: &gput{follow: delivery.FollowGrants, content: three}, - wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + kept: &gput{follow: grantsstate.FollowGrants, content: three}, + wantWrites: []string{three}, wantUsers: 4, wantSource: grantsstate.GrantsUserListSource, wantFollows: grantsstate.FollowGrants}, {name: "a grants key that turns unreadable keeps the loaded list", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, - grants: []gput{{follow: delivery.FollowGrants, content: three}, {unreadable: true, after: true}}, - wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + grants: []gput{{follow: grantsstate.FollowGrants, content: three}, {unreadable: true, after: true}}, + wantWrites: []string{three}, wantUsers: 4, wantSource: grantsstate.GrantsUserListSource, wantFollows: grantsstate.FollowGrants}, {name: "a grants key that is deleted keeps the loaded list", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, - grants: []gput{{follow: delivery.FollowGrants, content: three}, {deleted: true, after: true}}, - wantWrites: []string{three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + grants: []gput{{follow: grantsstate.FollowGrants, content: three}, {deleted: true, after: true}}, + wantWrites: []string{three}, wantUsers: 4, wantSource: grantsstate.GrantsUserListSource, wantFollows: grantsstate.FollowGrants}, {name: "a grants list that drops more than half is refused and the file kept", - before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: one}}, - onDisk: five, wantUsers: 2, wantErr: "drops more than half", wantRefused: true, wantSource: delivery.GrantsUserListSource}, + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: grantsstate.FollowGrants, content: one}}, + onDisk: five, wantUsers: 2, wantErr: "drops more than half", wantRefused: true, wantSource: grantsstate.GrantsUserListSource}, {name: "a failed write is said and kept", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, - grants: []gput{{follow: delivery.FollowGrants, content: three}}, failWrite: true, - wantWrites: []string{three}, wantUsers: 4, wantErr: "disk full", wantSource: delivery.GrantsUserListSource}, + grants: []gput{{follow: grantsstate.FollowGrants, content: three}}, failWrite: true, + wantWrites: []string{three}, wantUsers: 4, wantErr: "disk full", wantSource: grantsstate.GrantsUserListSource}, {name: "a grants list that does not open to its digest is refused and the file kept", - before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, badDigest: true}}, - onDisk: two, wantErr: "could not be had", wantRefused: true, wantSource: delivery.GrantsUserListSource}, + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: grantsstate.FollowGrants, content: three, badDigest: true}}, + onDisk: two, wantErr: "could not be had", wantRefused: true, wantSource: grantsstate.GrantsUserListSource}, {name: "a grants list sealed to another key is refused and the file kept", - before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, otherKey: true}}, - onDisk: two, wantErr: "sealed to another key", wantRefused: true, wantSource: delivery.GrantsUserListSource}, + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: grantsstate.FollowGrants, content: three, otherKey: true}}, + onDisk: two, wantErr: "sealed to another key", wantRefused: true, wantSource: grantsstate.GrantsUserListSource}, } { t.Run(c.name, func(t *testing.T) { var mu sync.Mutex @@ -194,7 +193,7 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { } for i, g := range gs { if g.unreadable || g.deleted { - u := agent.Update{Key: delivery.GrantsUserListKey, Value: []byte("not a user-list key"), Revision: uint64(i + 1), Deleted: g.deleted} + u := agent.Update{Key: grantsstate.GrantsUserListKey, Value: []byte("not a user-list key"), Revision: uint64(i + 1), Deleted: g.deleted} grantsAfter = append(grantsAfter, u) continue } @@ -203,21 +202,21 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { other, _ := seal.GenerateKeyPair() to = other.PublicKey } - digest := delivery.UserListDigest([]byte(g.content)) + digest := grantsstate.UserListDigest([]byte(g.content)) if g.badDigest { - digest = delivery.UserListDigest([]byte("another list")) + digest = grantsstate.UserListDigest([]byte("another list")) } - object, err := delivery.SealUserList([]byte(g.content), to) + object, err := grantsstate.SealUserList([]byte(g.content), to) if err != nil { t.Fatal(err) } - sealed[delivery.UserListObjectName(digest)] = object - raw, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: g.follow, Digest: digest, - Object: delivery.UserListObjectName(digest), SealedTo: delivery.SealingKeyID(to), Node: node, Revision: uint64(i + 1)}) + sealed[grantsstate.UserListObjectName(digest)] = object + raw, err := grantsstate.EncodeGrantsUserList(grantsstate.GrantsUserList{Follow: g.follow, Digest: digest, + Object: grantsstate.UserListObjectName(digest), SealedTo: grantsstate.SealingKeyID(to), Node: node, Revision: uint64(i + 1)}) if err != nil { t.Fatal(err) } - u := agent.Update{Key: delivery.GrantsUserListKey, Value: raw, Revision: uint64(i + 1)} + u := agent.Update{Key: grantsstate.GrantsUserListKey, Value: raw, Revision: uint64(i + 1)} switch { case c.kept != nil && i == len(gs)-1: keptRaw = raw @@ -296,7 +295,7 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { } if len(grantsAfter) > 0 { for until := time.Now().Add(5 * time.Second); ; time.Sleep(5 * time.Millisecond) { - if _, st := fl.State(); st.Source == delivery.GrantsUserListSource || time.Now().After(until) { + if _, st := fl.State(); st.Source == grantsstate.GrantsUserListSource || time.Now().After(until) { break } } @@ -355,11 +354,11 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) { t.Fatalf("an apply while following %s holds the composition's list: %v", c.wantFollows, hasList) } // A list on the machine that cannot be read stops the apply; it is never left out to be removed. - if c.wantFollows == delivery.FollowGrants { + if c.wantFollows == grantsstate.FollowGrants { readErr := New(Env{Node: node, OnDisk: func(json.RawMessage) ([]byte, error) { return nil, errors.New("permission denied") }}) - readErr.grants = &delivery.GrantsUserList{Follow: delivery.FollowGrants} + readErr.grants = &grantsstate.GrantsUserList{Follow: grantsstate.FollowGrants} if _, err := readErr.Hold(cmp); err == nil || !strings.Contains(err.Error(), "could not be read") { t.Fatalf("an unreadable list on the machine was held as %v", err) } -- 2.54.0 From c1a1efa62ab3caf90afac16e3f090d1e73b4eef2 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:52:24 +0100 Subject: [PATCH 45/73] =?UTF-8?q?Node-engine:=20provisions=20come=20from?= =?UTF-8?q?=20the=20provisioner's=20client,=20the=20catalogue's=20state=20?= =?UTF-8?q?addresses=20from=20its=20client=20and=20the=20builder's=20modul?= =?UTF-8?q?e;=20the=20health=20answer=20and=20the=20bus=20principal=20stay?= =?UTF-8?q?=20mesh-sdk's=20(ADR=200634=20=C2=A71,=20=C2=A75;=20issue=20196?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/filling.go | 2 +- cmd/mesh-host/filling_test.go | 2 +- cmd/mesh-host/provision_secrets.go | 2 +- go.mod | 1 + internal/buckets/buckets.go | 3 ++- internal/compose/frombus/states.go | 6 +++--- internal/compose/resolution/proofs_test.go | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/mesh-host/filling.go b/cmd/mesh-host/filling.go index fa241e8..c874f08 100644 --- a/cmd/mesh-host/filling.go +++ b/cmd/mesh-host/filling.go @@ -9,8 +9,8 @@ import ( "path/filepath" "sync/atomic" + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/provisions" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/filling" "github.com/novox/mesh-host/internal/link" diff --git a/cmd/mesh-host/filling_test.go b/cmd/mesh-host/filling_test.go index 7885eb7..c679076 100644 --- a/cmd/mesh-host/filling_test.go +++ b/cmd/mesh-host/filling_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/provisions" + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "github.com/novox/mesh-host/internal/filling" "github.com/novox/mesh-host/internal/store" diff --git a/cmd/mesh-host/provision_secrets.go b/cmd/mesh-host/provision_secrets.go index a99fca0..8d77c21 100644 --- a/cmd/mesh-host/provision_secrets.go +++ b/cmd/mesh-host/provision_secrets.go @@ -8,8 +8,8 @@ import ( "sort" "strings" + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provisions" ) // provisionSecrets fills a provision's value into a file the provisioner's client hands the node-engine to write diff --git a/go.mod b/go.mod index 4165d0b..1b86060 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-20261105000000-000000000000 diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index 13f551a..0cfa161 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -10,6 +10,7 @@ import ( assigner "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" wants "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/keys" ) @@ -47,7 +48,7 @@ const ( Verdicts = catalogHolder + "_verdicts" Candidates = catalogHolder + "_candidates" - catalogHolder = "mesh-delivery-indexer" + catalogHolder = catalogclient.Module ) // CandidateKey is the key of a group's candidate in Candidates: the group as a key part, a branch such as `fix/1.2` diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index 2ae2480..d59d302 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -30,9 +30,9 @@ const ( // The holders' names are kept here, in the engine's own code, as the names of the states a manifest's reads use // (ADR 0634 §1, finding F4); a seat's contract names a state locally, not the module that holds it. PlacementRead = meshmoduleassignerclient.PlacementRead - ModulesRead = "mesh-delivery-indexer.modules" - ManifestsRead = "mesh-delivery-indexer.manifests" - BuildsRead = "mesh-delivery-builder.builds" + ModulesRead = meshcatalogclient.ModulesRead + ManifestsRead = meshcatalogclient.ManifestsRead + BuildsRead = builds.BuilderModule + ".builds" HaveRead = "node-engine.have" ResolvedRead = "mesh-config.resolved" ) diff --git a/internal/compose/resolution/proofs_test.go b/internal/compose/resolution/proofs_test.go index 892f382..76d3686 100644 --- a/internal/compose/resolution/proofs_test.go +++ b/internal/compose/resolution/proofs_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" + provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provisions" "github.com/novox/mesh-host/internal/beside" ) -- 2.54.0 From ae26376a906242549d9260c425cf09e55fc4c54a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:22:15 +0100 Subject: [PATCH 46/73] =?UTF-8?q?Node-engine:=20while=20the=20seat=20list?= =?UTF-8?q?=20is=20unread,=20the=20node's=20own=20composition=20lets=20thr?= =?UTF-8?q?ough=20the=20holder=20of=20the=20seat=20mesh-catalog's=20resour?= =?UTF-8?q?ces=20and=20accounts=20over=20what=20runs=20(ADR=200636=20?= =?UTF-8?q?=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/selfcompose/selfcompose.go | 122 +++++++++++++++++++++++ internal/selfcompose/selfcompose_test.go | 62 ++++++++++++ 2 files changed, 184 insertions(+) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 3ba5644..e806827 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -28,6 +28,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -1784,6 +1785,18 @@ const WaitsForTheSeatList = "the node has not read the seat list, so it composes // cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1). const WaitsForTheSeatListOwn = "the node has not read the seat list, so it composes nothing new of itself and keeps what runs (novox/hq ADR 0636 §1)" +// WaitsForTheSeatListOwnPart is why the rest of the node's own composition is held: of it the node composes what the holder +// of the seat mesh-catalog on this node needs, so that holder can start and the list can be read (ADR 0636 §1). +const WaitsForTheSeatListOwnPart = "the node has not read the seat list, so it composes of itself only what the holder of the seat mesh-catalog needs " + + "and keeps what runs of the rest (novox/hq ADR 0636 §1)" + +// Unread is the gate of a node whose reader of the seat list could not start: it has no list and can never read one, so it +// acts on nothing new, as a reader that waits does, where a nil gate would act on everything (ADR 0636 §1). +type Unread struct{} + +// MayAct is false for every seat. +func (Unread) MayAct(...string) bool { return false } + // holdUntilListed takes out of compositions every module's composition that is new (no running composition has its // digest) while the reader may not act on its seats, and names the module in held: it keeps running what it runs, and // the pass after the list was read hands it. The node's own composition is held the same way, whatever it folds, under @@ -1808,6 +1821,16 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m continue } module, why = agent.NodeModule, WaitsForTheSeatListOwn + // The list is read from the holder of the seat mesh-catalog, which the node's own composition may have to + // start: its part is composed whatever the list says, over what the node's own composition runs, and only that. + if part, ok := c.holderPart(ctx, r, comp, running); ok { + compositions[key] = part + if held == nil { + held = map[string]string{} + } + held[module] = WaitsForTheSeatListOwnPart + continue + } } else if c.env.SeatList.MayAct(c.seatsOf(ctx, r, module)...) { continue } @@ -1822,6 +1845,105 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m return held } +// holderPart is the node's own composition as it runs with, of the new one comp, what the holders of the seat mesh-catalog +// on this node need: their resources and their accounts, each replacing the one of the same id or name or following the +// rest. False when the new composition has nothing of a holder: the caller holds the composition whole then. +func (c *Composer) holderPart(ctx context.Context, r reader, comp compositions.Composed, running []compositions.Composed) (compositions.Composed, bool) { + holders := c.claimants(ctx, r, meshcatalog.Seat) + if len(holders) == 0 { + return compositions.Composed{}, false + } + out := compositions.Composed{V: comp.V, Node: comp.Node, Plan: comp.Plan} + for _, run := range running { + if run.Module == "" { + out = run + out.Resources = slices.Clone(run.Resources) + out.Filled = slices.Clone(run.Filled) + out.Own.Identities = slices.Clone(run.Own.Identities) + break + } + } + belongs := func(module, id string) bool { + for _, h := range holders { + if module == h || strings.HasPrefix(id, h+".") { + return true + } + } + return false + } + touched := false + paths := map[string]bool{} + for _, raw := range comp.Resources { + var head struct{ ID, Module, Type, Path string } + if json.Unmarshal(raw, &head) != nil || !belongs(head.Module, head.ID) { + continue + } + touched = true + if head.Type == "file" { + paths[head.Path] = true + } + at := slices.IndexFunc(out.Resources, func(have json.RawMessage) bool { + var h struct{ ID string } + return json.Unmarshal(have, &h) == nil && h.ID == head.ID + }) + if at >= 0 { + out.Resources[at] = raw + } else { + out.Resources = append(out.Resources, raw) + } + } + for _, id := range comp.Own.Identities { + if !belongs(id.Module, "") { + continue + } + touched = true + at := slices.IndexFunc(out.Own.Identities, func(have declaration.Identity) bool { return have.Module == id.Module && have.Name == id.Name }) + if at >= 0 { + out.Own.Identities[at] = id + } else { + out.Own.Identities = append(out.Own.Identities, id) + } + } + for _, p := range comp.Filled { + if paths[p] && !slices.Contains(out.Filled, p) { + out.Filled = append(out.Filled, p) + } + } + return out, touched +} + +// claimants is the modules, sorted, whose manifest claims the seat. +func (c *Composer) claimants(ctx context.Context, r reader, seat string) []string { + c.mu.Lock() + recs := maps.Clone(c.modules) + c.mu.Unlock() + var out []string + for _, module := range slices.Sorted(maps.Keys(recs)) { + if recs[module].Manifest == "" { + continue + } + raw, err := r.Manifest(ctx, recs[module].Manifest) + if err != nil { + continue + } + var man struct { + Claims []struct { + Name string `json:"name"` + } `json:"claims"` + } + if json.Unmarshal(raw, &man) != nil { + continue + } + for _, cl := range man.Claims { + if cl.Name == seat { + out = append(out, module) + break + } + } + } + return out +} + // seatsOf is the seats a module claims, invokes or provides, from the manifest its module record names; none when it // cannot be read, which the reader judges as a module no bootstrapped seat names. func (c *Composer) seatsOf(ctx context.Context, r reader, module string) []string { diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index ecce6b7..750f836 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -16,6 +16,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" @@ -1849,3 +1850,64 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { t.Errorf("lemurs is still held after the read: %q", still) } } + +// ADR 0636 §1, the cycle of the node's own composition: while the list is unread the node's own composition is held, but +// a node whose new own composition must start the holder of the seat mesh-catalog composes of it, whatever the list says, +// the holder's resources and its account and nothing else, over what the node's own composition ran before; and the rest +// of the new one is handed once the list was read. +func TestAnUnreadSeatListLetsThroughOfTheOwnCompositionOnlyWhatTheHolderOfTheCatalogNeeds(t *testing.T) { + catalog := `{"module":"catalog","version":"1","claims":[{"name":"` + meshcatalog.Seat + `"}]}` + other := `{"module":"other","version":"1","claims":[{"name":"node-files"}]}` + c := New(newFakeBus().env()) + c.env.SeatList = &gate{} + c.modules = map[string]meshcatalog.ModulesByModuleValue{"catalog": {Manifest: "dc"}, "other": {Manifest: "do"}} + c.manifests = map[string][]byte{"dc": []byte(catalog), "do": []byte(other)} + res := func(id string) json.RawMessage { + return json.RawMessage(`{"id":"` + id + `","type":"directory","path":"/x/` + id + `"}`) + } + ran := compositions.Composed{Node: "n1", Resources: []json.RawMessage{res("base.dir")}, + Own: compositions.NodeOwn{Identities: []declaration.Identity{{Module: "base", Name: "mesh-base", UID: 1}}}} + next := compositions.Composed{Node: "n1", Resources: []json.RawMessage{res("base.dir"), res("catalog.dir"), res("other.dir")}, + Own: compositions.NodeOwn{Identities: []declaration.Identity{{Module: "base", Name: "mesh-base", UID: 1}, + {Module: "catalog", Name: "mesh-catalog", UID: 2}, {Module: "other", Name: "mesh-other", UID: 3}}}} + comps := map[string]compositions.Composed{"n1.node": next} + held := c.holdUntilListed(context.Background(), reader{c: c}, comps, nil, []compositions.Composed{ran}) + got, ok := comps["n1.node"] + if !ok { + t.Fatalf("the node's own composition is held whole, so the holder of the seat mesh-catalog is never started and the list never read") + } + var ids []string + for _, raw := range got.Resources { + var h struct{ ID string } + _ = json.Unmarshal(raw, &h) + ids = append(ids, h.ID) + } + var names []string + for _, i := range got.Own.Identities { + names = append(names, i.Name) + } + if !slices.Equal(ids, []string{"base.dir", "catalog.dir"}) || !slices.Equal(names, []string{"mesh-base", "mesh-catalog"}) { + t.Errorf("the own composition lets through resources %v and accounts %v, want what ran and the holder's alone", ids, names) + } + if held[agent.NodeModule] != WaitsForTheSeatListOwnPart { + t.Errorf("the rest of the own composition is held with %q, want %q", held[agent.NodeModule], WaitsForTheSeatListOwnPart) + } + // Nothing ran: a fresh node composes the holder's part alone. + comps = map[string]compositions.Composed{"n1.node": next} + c.holdUntilListed(context.Background(), reader{c: c}, comps, nil, nil) + if got := comps["n1.node"]; len(got.Resources) != 1 || len(got.Own.Identities) != 1 || got.Own.Identities[0].Name != "mesh-catalog" { + t.Errorf("a fresh node lets through %d resources and %d accounts, want the holder's one each", len(got.Resources), len(got.Own.Identities)) + } +} + +// ADR 0636 §1: a reader the node could not start is no reader that gates nothing: the node holds like one that waits. +func TestACompositionHoldsWhenTheSeatListReaderCouldNotStart(t *testing.T) { + gate := Unread{} + c := New(newFakeBus().env()) + comps := map[string]compositions.Composed{"n1.node": {Node: "n1"}} + c.env.SeatList = gate + held := c.holdUntilListed(context.Background(), reader{c: c}, comps, nil, nil) + if _, ok := held[agent.NodeModule]; !ok || len(comps) != 0 { + t.Errorf("held %v, comps %v: a reader that did not start must hold the node's own composition", held, comps) + } +} -- 2.54.0 From b0baa1783652d434a50d0065a3d8aa4ed134fe72 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:22:53 +0100 Subject: [PATCH 47/73] =?UTF-8?q?Node-engine:=20a=20reader=20of=20the=20se?= =?UTF-8?q?at=20list=20that=20could=20not=20start=20holds=20the=20composer?= =?UTF-8?q?,=20where=20none=20gated=20nothing=20(ADR=200636=20=C2=A71;=20i?= =?UTF-8?q?ssue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/nodeagent.go | 11 +++++++++-- cmd/mesh-host/seatlist_unread_test.go | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 cmd/mesh-host/seatlist_unread_test.go diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 6a9ff5b..e7d8642 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -593,10 +593,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // composer hands nothing new meanwhile, and the node says it has waited five minutes (novox/hq ADR 0636 §1). seats, err := startSeatList(run, session, node, say) if err != nil { - say(err.Error()) + // No reader is no gate that lets everything through: the node acts on nothing new, as one that waits does. + say(err.Error() + "; the node composes nothing new until it is started again and reads the list (novox/hq ADR 0636 §1)") } hooks := composerHooks{ - seatList: seats, + seatList: seats, + seatListFailed: err != nil, running: func() []compositions.Composed { if now := own.get(); now != nil { return runsAsWritten(now.Everything(), opts.state) @@ -639,6 +641,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // its own key, `.node`, under candidates. shadowHooks := hooks shadowHooks.refused, shadowHooks.kept, shadowHooks.certify, shadowHooks.seatList = nil, nil, nil, nil + shadowHooks.seatListFailed = false shadowEnv := composerEnv(session, node, say, shadowHooks) shadow := selfcompose.NewShadow(shadowEnv, func(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) { @@ -994,6 +997,8 @@ type composerHooks struct { certify func(ctx context.Context) error // seatList is this run's reader of the seat list: the composer hands nothing new until it was read (ADR 0636 §1). seatList *seatListState + // seatListFailed says the reader could not start: the composer then holds like a reader that waits (selfcompose.Unread). + seatListFailed bool } // selfComposer is the node's own composer: it composes what the seat's placement places on the node, and nothing while @@ -1047,6 +1052,8 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify} if hooks.seatList != nil { env.SeatList, env.SeatListChanged = hooks.seatList.reader, hooks.seatList.changed + } else if hooks.seatListFailed { + env.SeatList = selfcompose.Unread{} } return env } diff --git a/cmd/mesh-host/seatlist_unread_test.go b/cmd/mesh-host/seatlist_unread_test.go new file mode 100644 index 0000000..ef6c120 --- /dev/null +++ b/cmd/mesh-host/seatlist_unread_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-host/internal/selfcompose" +) + +// ADR 0636 §1: when the node's reader of the seat list could not start, the composer is gated by a reader that never +// allows, where a missing reader would let the node act on everything; the shadow, which applies nothing, stays ungated. +func TestAReaderThatCouldNotStartHoldsTheComposer(t *testing.T) { + failed := composerEnv(nil, "n1", func(string) {}, composerHooks{seatListFailed: true}) + if failed.SeatList == nil || failed.SeatList.MayAct() || failed.SeatList.MayAct("node-engine") { + t.Fatalf("the composer of a node whose reader did not start is gated by %v, want a gate that allows nothing", failed.SeatList) + } + if _, ok := failed.SeatList.(selfcompose.Unread); !ok { + t.Errorf("gate is %T", failed.SeatList) + } + if got := composerEnv(nil, "n1", func(string) {}, composerHooks{}).SeatList; got != nil { + t.Errorf("a composer with no reader asked for gates by %v, want none (the shadow)", got) + } +} -- 2.54.0 From e282e089171c21ee6fda06c1c448874bcd7122e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:28:02 +0100 Subject: [PATCH 48/73] =?UTF-8?q?Node-engine:=20a=20held=20own=20compositi?= =?UTF-8?q?on=20that=20was=20handed=20stays=20handed,=20as=20a=20held=20mo?= =?UTF-8?q?dule's=20does=20(ADR=200636=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/selfcompose/selfcompose.go | 7 ++- internal/selfcompose/selfcompose_test.go | 61 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index e806827..5519e1d 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -1029,8 +1029,11 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { out = append(out, agent.Update{Key: key, Value: raw, Revision: c.rev}) } for key := range c.handed { - if _, module, _, _, err := engcomp.ParseKey(key); err == nil && module != "" { - if _, isHeld := held[module]; isHeld { + if _, module, _, isNode, err := engcomp.ParseKey(key); err == nil { + if isNode { + module = agent.NodeModule // the node's own composition is held under its scope + } + if _, isHeld := held[module]; isHeld && module != "" { continue // held: what was handed of it stays, so the node keeps running it } } diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 750f836..c1f1e5c 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -20,6 +20,7 @@ import ( "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" + "maps" "os" "reflect" "slices" @@ -1851,6 +1852,66 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { } } +func TestAHeldOwnCompositionThatWasHandedIsNotDropped(t *testing.T) { + b := newFakeBus() + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) + digest := meshcatalogclient.ManifestDigest([]byte(lemurs)) + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs}) + key, _ := meshcatalogclient.ManifestsKey(digest) + b.kv[buckets.Manifests+"/"+key] = man + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + env := b.env() + g := &gate{} + changed := make(chan struct{}, 1) + env.SeatList, env.SeatListChanged = g, changed + passes := make(chan map[string]string, 8) + env.Refused = func(_ string, held map[string]string) { passes <- maps.Clone(held) } + c := New(env) + out, err := c.Watch(ctx, "n1.>") + if err != nil { + t.Fatal(err) + } + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) + have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) + want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) + nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) + b.put(t, buckets.Placement, "placement.n1", place) + b.put(t, buckets.Have, "n1.node", have) + b.put(t, buckets.Modules, "lemurs", mod) + b.put(t, buckets.Wants, "want.n1.lemurs", want) + b.put(t, buckets.Wants, "want.n1.node", nodeWant) + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + b.send(bucket, agent.Update{Caught: true}) + } + // The list is read: the node's own composition is handed. Then the reader cannot act again (it restarted and has + // not read): the composition is held, and what was handed of it stays, as a held module's does. + g.open.Store(true) + changed <- struct{}{} + for u := next(t, out); !(u.Key == "n1.node" && !u.Deleted); u = next(t, out) { + } + g.open.Store(false) + changed <- struct{}{} + for h := range passes { + if h[agent.NodeModule] == WaitsForTheSeatListOwn { + break + } + } + deadline := time.After(100 * time.Millisecond) + for { + select { + case u := <-out: + if u.Key == "n1.node" && u.Deleted { + t.Fatalf("the held own composition that was handed is deleted, and the node drops what it runs") + } + case <-deadline: + return + } + } +} + // ADR 0636 §1, the cycle of the node's own composition: while the list is unread the node's own composition is held, but // a node whose new own composition must start the holder of the seat mesh-catalog composes of it, whatever the list says, // the holder's resources and its account and nothing else, over what the node's own composition ran before; and the rest -- 2.54.0 From 2bcc34a59b41cc2a013b8176138e1a3e905dfe14 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:30:45 +0100 Subject: [PATCH 49/73] =?UTF-8?q?Node-engine=20test:=20a=20node=20whose=20?= =?UTF-8?q?own=20composition=20folds=20the=20holder=20of=20the=20seat=20me?= =?UTF-8?q?sh-catalog=20starts=20it=20before=20the=20list=20is=20read=20an?= =?UTF-8?q?d=20composes=20the=20rest=20after=20(ADR=200636=20=C2=A71;=20is?= =?UTF-8?q?sue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/selfcompose/selfcompose_test.go | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index c1f1e5c..0a4dcee 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -1961,6 +1961,90 @@ func TestAnUnreadSeatListLetsThroughOfTheOwnCompositionOnlyWhatTheHolderOfTheCat } } +// ADR 0636 §1, the whole case: a node that runs two modules inside its own composition (a build of the old form each), +// one of them the holder of the seat mesh-catalog, and reads no seat list. Its own composition, handed before the read, +// carries the holder; the read, said as an event, hands the rest, the other module's resources, with no timer. +func TestTheHolderOfTheCatalogIsStartedFromTheOwnCompositionBeforeTheListIsReadAndTheRestAfter(t *testing.T) { + catalog := `{"module":"catalog","version":"1","claims":[{"name":"` + meshcatalog.Seat + `"}],"capabilities":["systemd"],` + + `"resources":[{"id":"service","type":"service","unit":"catalog.service","state":"running","boot":"enabled"}]}` + other := `{"module":"other","version":"1","capabilities":["systemd"],` + + `"resources":[{"id":"service","type":"service","unit":"other.service","state":"running","boot":"enabled"},` + + `{"id":"extra","type":"service","unit":"extra.service","state":"running","boot":"enabled"}]}` + b := newFakeBus() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + env := b.env() + g := &gate{} + changed := make(chan struct{}, 1) + env.SeatList, env.SeatListChanged = g, changed + env.Running = func() []compositions.Composed { + return []compositions.Composed{{Node: "n1", Resources: []json.RawMessage{json.RawMessage(`{"id":"other.service","type":"service","unit":"other.service","state":"running","boot":"enabled"}`)}}} + } + c := New(env) + out, err := c.Watch(ctx, "n1.>") + if err != nil { + t.Fatal(err) + } + mods := map[string]string{"catalog": catalog, "other": other} + var placed []meshmoduleassigner.PlacedModule + for _, m := range []string{"catalog", "other"} { + digest := meshcatalogclient.ManifestDigest([]byte(mods[m])) + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: m, Manifest: mods[m]}) + mk, _ := meshcatalogclient.ManifestsKey(digest) + b.kv[buckets.Manifests+"/"+mk] = man + rec, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Manifest: digest, Repository: "novox/x", Commit: "c"}) + b.put(t, buckets.Modules, m, rec) + hk, _ := nodeengineclient.HaveKey("n1", m) + hv, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.put(t, buckets.Have, hk, hv) + placed = append(placed, meshmoduleassigner.PlacedModule{Module: m}) + } + place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: placed}) + have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) + b.put(t, buckets.Placement, "placement.n1", place) + b.put(t, buckets.Have, "n1.node", have) + b.put(t, buckets.Wants, "want.n1.node", nodeWant) + for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + b.send(bucket, agent.Update{Caught: true}) + } + servicesIn := func(u agent.Update) []string { + var comp compositions.Composed + if err := json.Unmarshal(u.Value, &comp); err != nil { + t.Fatalf("the own composition does not decode: %v", err) + } + var ids []string + for _, raw := range comp.Resources { + var h struct{ ID string } + _ = json.Unmarshal(raw, &h) + if strings.HasPrefix(h.ID, "catalog.") || strings.HasPrefix(h.ID, "other.") { + ids = append(ids, h.ID) + } + } + return ids + } + var first agent.Update + for first.Key == "" { + if u := next(t, out); u.Key == "n1.node" && !u.Deleted { + first = u + } + } + if got := servicesIn(first); !slices.Equal(got, []string{"other.service", "catalog.service"}) { + t.Fatalf("before the read the own composition carries services %v, want what ran and the holder's alone", got) + } + g.open.Store(true) + changed <- struct{}{} + for { + u := next(t, out) + if u.Key == "n1.node" && !u.Deleted { + if got := servicesIn(u); !slices.Equal(got, []string{"catalog.service", "other.service", "other.extra"}) { + t.Fatalf("after the read the own composition carries services %v, want what ran, the rest of the module and the holder", got) + } + return + } + } +} + // ADR 0636 §1: a reader the node could not start is no reader that gates nothing: the node holds like one that waits. func TestACompositionHoldsWhenTheSeatListReaderCouldNotStart(t *testing.T) { gate := Unread{} -- 2.54.0 From 4a57d8c90c3fc3058544a1347826bb6e9d24d823 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 17:38:04 +0100 Subject: [PATCH 50/73] =?UTF-8?q?The=20node-engine=20reads,=20records=20an?= =?UTF-8?q?d=20routes=20artifact=20references=20through=20the=20registry?= =?UTF-8?q?=20clients'=20reference=20forms=20(registryforms),=20not=20mesh?= =?UTF-8?q?-sdk=20catalogue's=20(ADR=200634=20=C2=A74,=20S-8;=20issue=2019?= =?UTF-8?q?66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go.mod | 3 +++ internal/compose/composed.go | 8 +++++--- internal/compose/frombus/frombus.go | 16 +++++++++------- internal/compose/resolution/artifacts.go | 13 +++++++------ internal/registryforms/registryforms.go | 13 +++++++++++++ internal/takein/takein.go | 8 +++++--- 6 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 internal/registryforms/registryforms.go diff --git a/go.mod b/go.mod index 1b86060..e26adb4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-20261105000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-20261105000000-000000000000 diff --git a/internal/compose/composed.go b/internal/compose/composed.go index a045795..f7fe9ad 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -4,16 +4,18 @@ import ( "encoding/json" "errors" "fmt" - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - engcomp "github.com/novox/mesh-host/internal/compositions" "slices" "sort" "strings" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + engcomp "github.com/novox/mesh-host/internal/compositions" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/registryforms" ) // Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as @@ -354,7 +356,7 @@ func ToolsRunning(running []engcomp.Composed) map[string][]catalogue.Bundle { case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")): name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID("")) if r.Path == resolution.BundlePath(owner, name) { - out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest}) + out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: registryforms.Forms.Recorded(r.Source), Digest: r.Digest}) } } } diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index d86ca91..a7cc735 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -35,6 +35,12 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "net/netip" + "slices" + "sort" + "strings" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" @@ -43,17 +49,13 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" engcomp "github.com/novox/mesh-host/internal/compositions" - "maps" - "net/netip" - "slices" - "sort" - "strings" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/registryforms" "github.com/novox/mesh-host/internal/takein" ) @@ -83,7 +85,7 @@ type Reader interface { // when the builder holds none. The composing node composes each of its modules from its build (issue 1126). Build(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) // Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it - // (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the + // (registryforms.Forms.Routed), checked against digest: a moved module's templates archive, read back for its texts as the // controller's take-in reads it (takein.Built). Archive(ctx context.Context, url, digest string) ([]byte, error) // Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`, @@ -834,7 +836,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m case address == "": return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in)) } - return r.Archive(ctx, catalogue.Routed(reference, address), digest) + return r.Archive(ctx, registryforms.Forms.Routed(reference, address), digest) } built := func(module, fp string, b *builds.BuilderBuild) (catalogue.Manifest, error) { if b.Manifest == "" { diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go index 67aad4b..dab2f86 100644 --- a/internal/compose/resolution/artifacts.go +++ b/internal/compose/resolution/artifacts.go @@ -5,6 +5,7 @@ import ( "strings" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/registryforms" ) // artifactsInto composes the artifact store's address into a resource's `image` and `source`. @@ -32,7 +33,7 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) if !ok { continue } - if _, kept := mcat.InArtifactStore(written); kept { + if _, kept := registryforms.Forms.InArtifactStore(written); kept { if with.ArtifactStore == "" { return fmt.Errorf( "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+ @@ -40,18 +41,18 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) "container-image-registry, or the machine holding it is not on the private network", module, resource["id"], written) } - resource[key] = mcat.Routed(written, with.ArtifactStore) + resource[key] = registryforms.Forms.Routed(written, with.ArtifactStore) imageSeatInto(resource, key, written, with) continue } if with.ArtifactStore == "" { continue } - recorded := mcat.Recorded(written) + recorded := registryforms.Forms.Recorded(written) if recorded == written { continue } - path, _ := mcat.InArtifactStore(recorded) + path, _ := registryforms.Forms.InArtifactStore(recorded) repository := path if at := strings.IndexAny(path, "@"); at >= 0 { repository = path[:at] @@ -59,7 +60,7 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) repository = path[:blobs] } if with.Built[repository] { - resource[key] = mcat.Routed(recorded, with.ArtifactStore) + resource[key] = registryforms.Forms.Routed(recorded, with.ArtifactStore) imageSeatInto(resource, key, written, with) } } @@ -80,7 +81,7 @@ func imageSeatInto(resource map[string]any, key, written string, with mcat.Rende } // The seat a reference of the registry form names; the older forms came through the artifact store's. seat := artifactSeat - if named, _, ok := mcat.InRegistrySeat(written); ok { + if named, _, ok := registryforms.Forms.InRegistrySeat(written); ok { seat = named } if with.SeatHolder[seat] == "" { diff --git a/internal/registryforms/registryforms.go b/internal/registryforms/registryforms.go new file mode 100644 index 0000000..3682443 --- /dev/null +++ b/internal/registryforms/registryforms.go @@ -0,0 +1,13 @@ +// Package registryforms is how the node-engine reads an artifact's reference: the registry clients' reference forms +// (novox/hq ADR 0605 §9, ADR 0634 §4), with the two registry seats' names from their contracts. The forms are the +// registry clients' (mesh-delivery's contracts), not mesh-sdk's; this package only gives them the seats' names. +package registryforms + +import ( + "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + containerimageregistry "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + ociartifactregistry "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" +) + +// Forms reads, records and routes a reference in the registry seat's form and the older artifact-store one. +var Forms = reference.Seats{Image: containerimageregistry.Seat, Object: ociartifactregistry.Seat} diff --git a/internal/takein/takein.go b/internal/takein/takein.go index a47bb27..91cb751 100644 --- a/internal/takein/takein.go +++ b/internal/takein/takein.go @@ -18,14 +18,16 @@ import ( "context" "errors" "fmt" - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "io" "strings" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/registryforms" ) -// Archive reads the archive a recorded reference names (catalogue.Recorded: the artifact store's address left out), +// Archive reads the archive a recorded reference names (registryforms.Forms.Recorded: the artifact store's address left out), // checked against its digest. type Archive func(ctx context.Context, reference, digest string) ([]byte, error) @@ -82,7 +84,7 @@ func Built(ctx context.Context, m catalogue.Manifest, artifacts []builds.BuiltAr if kind == "" { kind = d.Kind } - b := catalogue.Built{Name: a.Name, Kind: kind, Reference: catalogue.Recorded(a.Reference), Digest: a.Digest} + b := catalogue.Built{Name: a.Name, Kind: kind, Reference: registryforms.Forms.Recorded(a.Reference), Digest: a.Digest} if launchers := catalogue.Launchers(d.Language, d.Entrypoints); len(launchers) > 0 { b.Launchers = launchers } -- 2.54.0 From d69fd304cc7c063a4d47838beb8dec821bde2818 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:56:39 +0000 Subject: [PATCH 51/73] Require every mesh package of the group at the floor version (ADR 0573 sections 2 and 4, issue 1966) A member's go.mod names the oldest version a module accepts; the check builds the group's own heads whatever it names. go.sum lines of mesh packages are left out. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go.mod | 56 ++++++++++++++++++++++++++++++-------------------------- go.sum | 14 -------------- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/go.mod b/go.mod index e26adb4..70226eb 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,25 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0 - git.novox.be/novox/mesh-delivery/sdk v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 @@ -29,13 +29,13 @@ require ( ) require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-20261105000000-000000000000 - git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 - git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect @@ -45,3 +45,7 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/time v0.16.0 // indirect ) + +require ( + git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/go.sum b/go.sum index 87c9bb8..8ca93df 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,3 @@ -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM= -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= -git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 h1:3FTxvHrv3Hv+ZUbp/ezTrndqdgiS+Z6EUG2XGYJNJr8= -git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/V/1wfyQwjE9VbPLsFtkf6uX+SHKUXX34s5JscDuGAs= -git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w= -git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0= -git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639 h1:LyN9de63PwLy2VL0RZWjSsoRf0kSX5zisMpeDvKl42k= -git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-20261104174344-ae5266e77639/go.mod h1:8dL18Gj/VOzNIBSJGlAdFHc9XonCcsZWm/JcjcpwwbU= -git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4= -git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A= -git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM= -git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From aa40b107c9c6bec33ed3c2e1d62f77a81abb5602 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 18:37:28 +0100 Subject: [PATCH 52/73] No client names a holder: each reader keeps its own constant, held to its manifest (ADR 0625 section 4, issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/buckets/buckets.go | 23 +++++++++++++---------- internal/buckets/buckets_test.go | 15 +++++++++++++++ internal/compose/frombus/states.go | 12 +++++++----- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index 0cfa161..7aa1075 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -5,12 +5,11 @@ package buckets import ( "errors" + vault "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" - assigner "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" wants "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" - catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/keys" ) @@ -32,23 +31,27 @@ const HaveHistory = 20 // module as every state is (ADR 0634 §1, finding F4: the holder's name is kept here, in the engine's code). const ( // Placement holds a node's placement, `.node`, and each of its modules, `.`. - Placement = assigner.PlacementBucket + Placement = AssignerHolder + "_" + seat.StatePlacement // PlacementCandidates holds a node's candidate of a proposal, `.`. - PlacementCandidates = assigner.Module + "_" + seat.StateCandidates + PlacementCandidates = AssignerHolder + "_" + seat.StateCandidates // Wants holds what each node should run, `want..` and `want..node`. - Wants = assigner.Module + "_" + wants.StateWants + Wants = AssignerHolder + "_" + wants.StateWants + + // AssignerHolder is the module holding the seat mesh-module-assigner and the seat mesh-wants. + AssignerHolder = "mesh-delivery-assigner" ) // The states of the catalogue seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4). const ( // Modules holds a module's source, ``; Manifests a manifest by its digest; Verdicts the judge's verdict of a // manifest by its digest; Candidates a group's candidate, by the group's key part (CandidateKey). - Modules = catalogHolder + "_modules" - Manifests = catalogHolder + "_manifests" - Verdicts = catalogHolder + "_verdicts" - Candidates = catalogHolder + "_candidates" + Modules = CatalogHolder + "_modules" + Manifests = CatalogHolder + "_manifests" + Verdicts = CatalogHolder + "_verdicts" + Candidates = CatalogHolder + "_candidates" - catalogHolder = catalogclient.Module + // CatalogHolder is the module holding the seat mesh-catalog. + CatalogHolder = "mesh-delivery-indexer" ) // CandidateKey is the key of a group's candidate in Candidates: the group as a key part, a branch such as `fix/1.2` diff --git a/internal/buckets/buckets_test.go b/internal/buckets/buckets_test.go index d24750b..cedb16b 100644 --- a/internal/buckets/buckets_test.go +++ b/internal/buckets/buckets_test.go @@ -54,3 +54,18 @@ func TestACandidateKeyEscapesTheGroup(t *testing.T) { t.Error("a key was made for no group") } } + +// The holders' buckets are the names the grants part grants and the holders' manifests declare: no client carries a +// holder (ADR 0625 §4), so these rows are the one place the node-engine's code says them. +func TestTheHoldersBucketsAreTheirStatesBuckets(t *testing.T) { + for name, c := range map[string][2]string{ + "Placement": {Placement, "mesh-delivery-assigner_placement"}, "PlacementCandidates": {PlacementCandidates, "mesh-delivery-assigner_candidates"}, + "Wants": {Wants, "mesh-delivery-assigner_wants"}, "Modules": {Modules, "mesh-delivery-indexer_modules"}, + "Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, "Verdicts": {Verdicts, "mesh-delivery-indexer_verdicts"}, + "Candidates": {Candidates, "mesh-delivery-indexer_candidates"}, + } { + if c[0] != c[1] { + t.Errorf("%s = %q, want %q", name, c[0], c[1]) + } + } +} diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index d59d302..0eb785d 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -12,6 +12,9 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "strings" + "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" @@ -19,19 +22,18 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/resolution" - "strings" - "time" ) // The states a module names in its manifest's `reads` to compose through StatesReader. const ( // The holders' names are kept here, in the engine's own code, as the names of the states a manifest's reads use // (ADR 0634 §1, finding F4); a seat's contract names a state locally, not the module that holds it. - PlacementRead = meshmoduleassignerclient.PlacementRead - ModulesRead = meshcatalogclient.ModulesRead - ManifestsRead = meshcatalogclient.ManifestsRead + PlacementRead = buckets.AssignerHolder + ".placement" + ModulesRead = buckets.CatalogHolder + ".modules" + ManifestsRead = buckets.CatalogHolder + ".manifests" BuildsRead = builds.BuilderModule + ".builds" HaveRead = "node-engine.have" ResolvedRead = "mesh-config.resolved" -- 2.54.0 From 24b7740b8021cad505ef18815a0e81ef857434ac Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 18:45:50 +0100 Subject: [PATCH 53/73] RemovedDeliveryVerbs leaves mesh-sdk for the seat mesh-delivery's client (ADR 0634 section 2, issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/compose/resolution/delivery_seat.go | 3 +- .../compose/resolution/delivery_seat_test.go | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 internal/compose/resolution/delivery_seat_test.go diff --git a/internal/compose/resolution/delivery_seat.go b/internal/compose/resolution/delivery_seat.go index a7353da..851b592 100644 --- a/internal/compose/resolution/delivery_seat.go +++ b/internal/compose/resolution/delivery_seat.go @@ -1,12 +1,13 @@ package resolution import ( + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat. func deliveryRemovedInR6(name string) mcat.Verb { - return mcat.Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + mcat.RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true} + return mcat.Verb{Name: name, Description: meshdeliveryclient.RemovedPrefix + meshdeliveryclient.RemovedVerbs()[name], Input: schema(nil, nil), Optional: true} } // deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2 diff --git a/internal/compose/resolution/delivery_seat_test.go b/internal/compose/resolution/delivery_seat_test.go new file mode 100644 index 0000000..5088f6d --- /dev/null +++ b/internal/compose/resolution/delivery_seat_test.go @@ -0,0 +1,30 @@ +package resolution + +import ( + "testing" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" +) + +// Each verb release 6 removed from the delivery seat is a row refusing it by name with the replacement the seat's client +// gives (the list is the contract's, not the SDK's: novox/hq ADR 0634 §2), and the rows are exactly those verbs. +func TestTheDeliverySeatRefusesTheRemovedVerbsByNameWithTheClientsReplacement(t *testing.T) { + removed := meshdeliveryclient.RemovedVerbs() + rows := 0 + for _, v := range deliveryVerbs() { + want, isRemoved := removed[v.Name] + if !isRemoved { + continue + } + rows++ + if v.Description != meshdeliveryclient.RemovedPrefix+want || !v.Optional { + t.Errorf("%s = %q (optional %v), want %q", v.Name, v.Description, v.Optional, meshdeliveryclient.RemovedPrefix+want) + } + } + if rows != len(removed) { + t.Errorf("%d rows refuse a removed verb, the client names %d", rows, len(removed)) + } + if len(removed) != 5 { + t.Errorf("the removed verbs are %v", removed) + } +} -- 2.54.0 From b75495594ad3e72d98d9b858e5b8c3e691e986c9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 19:23:39 +0100 Subject: [PATCH 54/73] The node-engine's account hears what its manifest consumes: the view's settled is a consumes line, not a special case in the grants part (ADR 0625 section 4, issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/settled.go | 4 ++-- cmd/mesh-host/settled_manifest_test.go | 28 ++++++++++++++++++++++++++ module.json | 1 + 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 cmd/mesh-host/settled_manifest_test.go diff --git a/cmd/mesh-host/settled.go b/cmd/mesh-host/settled.go index 5a7dd82..bfec0b6 100644 --- a/cmd/mesh-host/settled.go +++ b/cmd/mesh-host/settled.go @@ -11,8 +11,8 @@ import ( // settledEvent is where the status view says a module has settled on a node: its have reached its want, running and healthy // (design 47; the view's `settled`, said once for a module on a node at one composition). The engine hears it for every module // and tells its agent, which tries again, once per settled, a failed apply that waits on that module as the holder of the store -// its image comes from (design 51 *distribution's first delivery*, issue 1966 ENG-11). The grants part grants the engine this -// subject (NodeAgentHears); a refused subscription is said, and made again when the next link opens. +// its image comes from (design 51 *distribution's first delivery*, issue 1966 ENG-11). The engine's manifest consumes it (`mesh-delivery-view.settled`), +// and that line alone grants the engine's account the subject (TestTheSettledEventIsTheManifestsConsume); a refused subscription is said, and made again when the next link opens. const settledEvent = "mesh.mod.mesh-delivery-view.event.settled" // settledBody is what of the event the engine reads: the module that settled, on whichever node. diff --git a/cmd/mesh-host/settled_manifest_test.go b/cmd/mesh-host/settled_manifest_test.go new file mode 100644 index 0000000..24d4b75 --- /dev/null +++ b/cmd/mesh-host/settled_manifest_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "strings" + "testing" +) + +// The subject the engine hears for a store settling is the event its manifest consumes: the grants part grants an account +// what its manifest consumes and nothing named in code (novox/hq ADR 0625 §4). +func TestTheSettledEventIsTheManifestsConsume(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Consumes []string `json:"consumes"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + emitter, event, _ := strings.Cut(strings.TrimPrefix(settledEvent, "mesh.mod."), ".event.") + if !slices.Contains(m.Consumes, emitter+"."+event) { + t.Errorf("the engine hears %s, its manifest consumes %v", settledEvent, m.Consumes) + } +} diff --git a/module.json b/module.json index 2f3da06..8c19c88 100644 --- a/module.json +++ b/module.json @@ -20,6 +20,7 @@ "consumes": [ "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", + "mesh-delivery-view.settled", "restic.backed-up" ], "state": [ -- 2.54.0 From c40a79e601b9c2c6014cdaf490176b00f2fad2fb Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 19:25:19 +0100 Subject: [PATCH 55/73] The manifest test lists the view's settled among the engine's consumes (issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- packaging/operator_keys_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/operator_keys_test.go b/packaging/operator_keys_test.go index 487579a..1b86b25 100644 --- a/packaging/operator_keys_test.go +++ b/packaging/operator_keys_test.go @@ -66,8 +66,8 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - // The backup event, and the two events of the seat that serves the seat list (ADR 0636 §4). - if want := []string{"mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "restic.backed-up"}; !slices.Equal(m.Consumes, want) { + // The backup event, the status view's settled, and the two events of the seat that serves the seat list (ADR 0636 §4). + if want := []string{"mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-view.settled", "restic.backed-up"}; !slices.Equal(m.Consumes, want) { t.Fatalf("consumes %v, want %v", m.Consumes, want) } // 774: the acts bucket mesh-host_acts is not declared as state for now: a declared state of a module with no account -- 2.54.0 From e1b9f3e095038f2e19c8599f5359111f025b6271 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 19:36:21 +0100 Subject: [PATCH 56/73] No client names the holder of the seat mesh-provisioner: each reader keeps its own constant, held to its manifest (ADR 0625 section 4, issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/filling.go | 6 +++--- cmd/mesh-host/provision_secrets.go | 3 ++- internal/buckets/buckets.go | 9 +++++++++ internal/buckets/buckets_test.go | 3 ++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cmd/mesh-host/filling.go b/cmd/mesh-host/filling.go index c874f08..e508678 100644 --- a/cmd/mesh-host/filling.go +++ b/cmd/mesh-host/filling.go @@ -9,9 +9,9 @@ import ( "path/filepath" "sync/atomic" - provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/filling" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/store" @@ -130,7 +130,7 @@ func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSe p.list = func(ctx context.Context, prefix string) (map[string][]byte, error) { wctx, cancel := context.WithCancel(ctx) defer cancel() - updates, err := queue.KVWatch(wctx, provisions.ProvisionsBucket, prefix+">") + updates, err := queue.KVWatch(wctx, buckets.Provisions, prefix+">") if err != nil { return nil, err } @@ -141,7 +141,7 @@ func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSe return nil, ctx.Err() case u, ok := <-updates: if !ok { - return nil, fmt.Errorf("the watch of %s ended before it caught up", provisions.ProvisionsBucket) + return nil, fmt.Errorf("the watch of %s ended before it caught up", buckets.Provisions) } switch { case u.Caught: diff --git a/cmd/mesh-host/provision_secrets.go b/cmd/mesh-host/provision_secrets.go index 8d77c21..6af67fb 100644 --- a/cmd/mesh-host/provision_secrets.go +++ b/cmd/mesh-host/provision_secrets.go @@ -10,6 +10,7 @@ import ( provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/buckets" ) // provisionSecrets fills a provision's value into a file the provisioner's client hands the node-engine to write @@ -92,7 +93,7 @@ func byName(keys map[string][]byte, name string) (provisions.Provision, string, } switch len(hit) { case 0: - return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", provisions.ProvisionsRead) + return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", buckets.ProvisionsRead) case 1: return got, hit[0], nil } diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index 7aa1075..c83279d 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -41,6 +41,15 @@ const ( AssignerHolder = "mesh-delivery-assigner" ) +// The state of the provisioner seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4). +const ( + // ProvisionerHolder is the module holding the seat mesh-provisioner; Provisions its state provisions, which the engine + // watches for the key of a provision it fills (`.>`), ProvisionsRead the name a manifest's reads gives it. + ProvisionerHolder = "mesh-provisioner" + Provisions = ProvisionerHolder + "_provisions" + ProvisionsRead = ProvisionerHolder + ".provisions" +) + // The states of the catalogue seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4). const ( // Modules holds a module's source, ``; Manifests a manifest by its digest; Verdicts the judge's verdict of a diff --git a/internal/buckets/buckets_test.go b/internal/buckets/buckets_test.go index cedb16b..4700d39 100644 --- a/internal/buckets/buckets_test.go +++ b/internal/buckets/buckets_test.go @@ -62,7 +62,8 @@ func TestTheHoldersBucketsAreTheirStatesBuckets(t *testing.T) { "Placement": {Placement, "mesh-delivery-assigner_placement"}, "PlacementCandidates": {PlacementCandidates, "mesh-delivery-assigner_candidates"}, "Wants": {Wants, "mesh-delivery-assigner_wants"}, "Modules": {Modules, "mesh-delivery-indexer_modules"}, "Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, "Verdicts": {Verdicts, "mesh-delivery-indexer_verdicts"}, - "Candidates": {Candidates, "mesh-delivery-indexer_candidates"}, + "Candidates": {Candidates, "mesh-delivery-indexer_candidates"}, "Provisions": {Provisions, "mesh-provisioner_provisions"}, + "ProvisionsRead": {ProvisionsRead, "mesh-provisioner.provisions"}, } { if c[0] != c[1] { t.Errorf("%s = %q, want %q", name, c[0], c[1]) -- 2.54.0 From 8717ec76d4981c1c71f9a4fcfa972b865b5d3fa8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 22:08:40 +0100 Subject: [PATCH 57/73] The node-engine follows the module-assignment seat: assignments, not placement; the seat's packages move to mesh-contracts and mesh-delivery (issue 1966) The state, events and types of the seat mesh-module-assigner say assignments; the legacy node key form is gone from the reads; the dead seat-verb list of the retired name is deleted. The placement guard and the placed-file sense stay. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/enrol_seat.go | 2 +- cmd/mesh-host/nodeagent.go | 26 +- cmd/mesh-host/nodeagent_integration_test.go | 78 +++--- cmd/mesh-host/trust.go | 51 ++-- cmd/mesh-host/whatruns_test.go | 8 +- go.mod | 6 +- internal/agent/agent.go | 2 +- internal/agent/agent_test.go | 2 +- internal/bootstrap/bootstrap.go | 2 +- internal/bootstrap/phase2.go | 2 +- internal/buckets/buckets.go | 14 +- internal/buckets/buckets_test.go | 2 +- internal/buckets/bucketstest/bucketstest.go | 16 +- internal/compose/composed.go | 2 +- internal/compose/credentials.go | 2 +- internal/compose/credentials_test.go | 2 +- internal/compose/frombus/answer.go | 14 +- internal/compose/frombus/answer_rows_test.go | 60 ++--- internal/compose/frombus/export.go | 16 +- internal/compose/frombus/frombus.go | 124 +++++----- internal/compose/frombus/frombus_test.go | 102 ++++---- .../frombus/frombustest/frombustest.go | 10 +- .../frombus/frombustest/frombustest_test.go | 37 +-- internal/compose/frombus/held_rows_test.go | 6 +- internal/compose/frombus/states.go | 24 +- internal/compose/frombus/twice.go | 2 +- internal/compose/fromruntime/fromruntime.go | 2 +- internal/compose/input.go | 2 +- internal/compose/network.go | 6 +- internal/compose/network_test.go | 2 +- internal/compose/render.go | 2 +- internal/compose/rendering.go | 2 +- internal/compose/resolution/declaration.go | 2 +- internal/compose/resolution/enrolment_seat.go | 8 +- internal/compose/resolution/keyed_test.go | 8 +- internal/compose/resolution/placement_seat.go | 59 ----- internal/compose/resolution/proofs_test.go | 8 +- internal/composer/composer.go | 18 +- internal/composer/composer_test.go | 10 +- internal/compositions/candidate.go | 4 +- internal/compositions/composed_compare.go | 2 +- internal/inventory/inventory_test.go | 2 +- internal/judge/aliases.go | 2 +- internal/judge/answer_test.go | 2 +- internal/link/enrol_seat.go | 2 +- internal/link/have.go | 4 +- internal/link/have_test.go | 2 +- internal/link/history_integration_test.go | 6 +- internal/link/watch.go | 4 +- internal/nodegrant/testdata/node.json | 12 +- internal/selfcompose/candidate.go | 16 +- internal/selfcompose/candidate_test.go | 13 +- internal/selfcompose/composedstate.go | 2 +- internal/selfcompose/composedstate_test.go | 11 +- internal/selfcompose/selfcompose.go | 78 +++--- internal/selfcompose/selfcompose_test.go | 224 ++++++++++-------- internal/selfcompose/shadow.go | 68 +++--- .../modules/route-proxy/module.json.captured | 2 +- 58 files changed, 581 insertions(+), 616 deletions(-) delete mode 100644 internal/compose/resolution/placement_seat.go diff --git a/cmd/mesh-host/enrol_seat.go b/cmd/mesh-host/enrol_seat.go index 787bb90..f86d187 100644 --- a/cmd/mesh-host/enrol_seat.go +++ b/cmd/mesh-host/enrol_seat.go @@ -65,7 +65,7 @@ func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, n } fmt.Printf("\nenrolled as %s through the seat mesh-node-enroller, dialled as %s and confirmed\n", answer.Node, answer.Login) fmt.Printf(" identity %s\n", identityPath) - fmt.Println(" nothing is applied until the placement of this node names a module") + fmt.Println(" nothing is applied until the assignment of this node names a module") return nil } diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index e7d8642..46a8fd7 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -46,7 +46,7 @@ func (w bucketWatch) Watch(ctx context.Context, pattern string) (<-chan agent.Up return w.updates(ctx, in), nil } -// WatchAwait is Watch for a state that its holder makes when it starts (the placement seat's): a bucket not on the bus +// WatchAwait is Watch for a state that its holder makes when it starts (the assignment seat's): a bucket not on the bus // yet is waited for on the bus's word that it was created (link.Session.KVWatchAwait), and waiting is told why. func (w bucketWatch) WatchAwait(ctx context.Context, pattern string, waiting func(error)) (<-chan agent.Update, error) { in, err := w.session.KVWatchAwait(ctx, w.bucket, pattern, waiting) @@ -454,7 +454,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus return } var ag *agent.Agent - // The kept release-5 declaration is no longer read once every module the seat's placement puts here has a have of its + // The kept release-5 declaration is no longer read once every module the seat's assignment puts here has a have of its // own (ADR 0550 §2: its signature is the controller's, trusted no more after the lease write). // Nor before this engine keeps the node's own as it runs (applied/node.json): until then the kept declaration is the one // record of what the node's own composition carries, its folded modules among them (issue 774, review of the @@ -637,7 +637,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus return certifyThisNode(ctx, opts.state, node, trust, session.AskVault, func() { sayFacts(ctx, ag) }) }, } - // And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into + // And its answer to every assignment candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into // its own key, `.node`, under candidates. shadowHooks := hooks shadowHooks.refused, shadowHooks.kept, shadowHooks.certify, shadowHooks.seatList = nil, nil, nil, nil @@ -650,10 +650,10 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with // Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's - // composition bucket) is never applied. While the seat's placement holds no key for this node it composes - // nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again. + // composition bucket) is never applied. While the seat's assignment holds no key for this node it composes + // nothing and keeps running what it runs; a assignment it cannot watch stops the agent, said, and it begins again. composer := selfComposer(session, node, hooks, say) - machinesNow.Store(composer) // the machines a failing network part points at, by the placements (issue 774) + machinesNow.Store(composer) // the machines a failing network part points at, by the assignments (issue 774) composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does @@ -792,7 +792,7 @@ func busyModules() []string { return out } -// machinesNow is the composer whose placements name the mesh's machines by address, for the health report's targets +// machinesNow is the composer whose assignments name the mesh's machines by address, for the health report's targets // (writeHealthReport); nil on a node that does not compose its own, whose parts then name no target. var machinesNow atomic.Pointer[selfcompose.Composer] @@ -800,11 +800,11 @@ var machinesNow atomic.Pointer[selfcompose.Composer] // compositions or no run is going: what the verb compose asks a pass of (issue 774). var composerNow atomic.Pointer[selfcompose.Composer] -// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil +// shadowNow is the shadow of the run now, which answers assignment candidates and the verb compose-candidate; nil // between runs. var shadowNow atomic.Pointer[selfcompose.Shadow] -// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of +// keptRetired says the kept declaration is read no more: every module the seat's assignment puts on this node has a have of // its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9). var keptRetired atomic.Bool @@ -1001,8 +1001,8 @@ type composerHooks struct { seatListFailed bool } -// selfComposer is the node's own composer: it composes what the seat's placement places on the node, and nothing while -// the placement holds no key for it (issue 1126). +// selfComposer is the node's own composer: it composes what the seat's assignment places on the node, and nothing while +// the assignment holds no key for it (issue 1126). func selfComposer(session *link.Session, node string, hooks composerHooks, say func(string)) *selfcompose.Composer { env := composerEnv(session, node, say, hooks) // And it keeps each placed module's data items in its own state data, which the node's disk-load watches (novox/hq @@ -1018,11 +1018,11 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f } // composerEnv is what the node's composers read of the bus, the vault and the agent: its own composer and its answer -// to placement candidates (selfcompose.Shadow) alike. +// to assignment candidates (selfcompose.Shadow) alike. func composerEnv(session *link.Session, node string, say func(string), hooks composerHooks) selfcompose.Env { env := selfcompose.Env{Node: node, Watch: func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) { - if bucket == buckets.Placement || bucket == buckets.PlacementCandidates { + if bucket == buckets.Assignments || bucket == buckets.ProposalCandidates { // The seat's states are made when their holder starts: a node-engine that starts first waits for the // bus's word that the bucket was created, and the shadow and the composer say they wait. return bucketWatch{session, bucket}.WatchAwait(ctx, pattern, func(err error) { diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index f8fcfbc..7a3551e 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -7,13 +7,13 @@ import ( "crypto/ed25519" "encoding/json" "errors" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" builderbuilds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" @@ -230,16 +230,16 @@ func within(t *testing.T, d time.Duration, what string, ok func() bool) { } // Issue 1126 (ADR 0547 §2, the milestone's H8): the node agent started whole against a real bus, once the catalogue -// places its node (the placement written after it started), composes a module from its want, the indexer's manifest, its record and its mesh-config value, and applies it, +// places its node (the assignment written after it started), composes a module from its want, the indexer's manifest, its record and its mesh-config value, and applies it, // with no key in the controller's compositions; a changed config value is composed again on its event and applied as a -// revision of what runs. And a catalogue pull request's placement candidate equal to its placement is answered "same" +// revision of what runs. And a catalogue pull request's assignment candidate equal to its assignment is answered "same" // in the node's own key, and nothing is applied for it (ADR 0559 §4, subtask 10 of the milestone). // // And (issue 1126, ADR 0560) it reads, through its own session under the grant the grants part composes for it // (nodegrant.For with the served key, as the grants part grants it), the build of a placed module that declares one and the one config value // a provider on another node serves from (a mail domain); under a grant without that served key the bus refuses the // read and the node composes nothing. -func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { +func TestNatsANodeTheAssignmentsNameComposesAndAppliesItsOwn(t *testing.T) { t.Run("without the served key the bus refuses the read", func(t *testing.T) { b := newItBed(t) stop, _ := h8(t, b, false) @@ -260,7 +260,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) stop, st := h8(t, b, true) defer stop() - placement, resolved, candidates := st.placement, st.resolved, st.candidates + assignment, resolved, candidates := st.assignment, st.resolved, st.candidates hkey, place, fp := st.hkey, st.place, st.fp within(t, 5*time.Second, "the node did not compose and apply its own module once placed", func() bool { return b.file("web") == "greeting=bonjour\n" }) // ADR 0565 §6 (1953): the node's engine writes its own key of the state packages, under the grant the grants part @@ -277,7 +277,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { t.Errorf("the node's packages key was written again with no package changed: %v, was %v", e, written) } t.Run("one answer per candidate revision", func(t *testing.T) { h8once(t, b, resolved, candidates, place) }) - h8back(t, b, placement, resolved, candidates, hkey) + h8back(t, b, assignment, resolved, candidates, hkey) } // h8once is issue 1126, 2026-10-28 (shanks answered probe/1126-seed-7 at revision 84 every 13-17 s, its bus flooded): @@ -290,7 +290,7 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place [] defer b.mu.Unlock() n := 0 for _, l := range b.said { - if strings.HasPrefix(l, "answering placement candidate feat-x ") { + if strings.HasPrefix(l, "answering assignment candidate feat-x ") { n++ } } @@ -320,8 +320,8 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place [] if got := answering(); got != settled { t.Errorf("feat-x answered %d more time(s) at the same revision, with nothing it is composed from changed", got-settled) } - same, _ := meshmoduleassignerclient.DecodePlacement(place) - craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Placement: &same}) + same, _ := meshmoduleassignerclient.DecodeAssignments(place) + craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Assignments: &same}) if err != nil { t.Fatal(err) } @@ -336,11 +336,11 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place [] } } -// h8back is A7 of issue 1126: no composition made elsewhere is applied. A candidate deleting the node's placement is -// refused, never answered as a return to the controller's compositions; the placement key deleted leaves the agent on +// h8back is A7 of issue 1126: no composition made elsewhere is applied. A candidate deleting the node's assignment is +// refused, never answered as a return to the controller's compositions; the assignment key deleted leaves the agent on // its own composer, composing nothing and keeping what runs, said in the journal, the node's have still saying it // composes its own. -func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValue, hkey string) { +func h8back(t *testing.T, b *itBed, assignment, resolved, candidates nats.KeyValue, hkey string) { t.Helper() node := func() nodeengine.HaveByNodeNodeValue { e, err := b.haves.Get(hkey) @@ -361,7 +361,7 @@ func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValu if _, err := candidates.Put(ckey, craw); err != nil { t.Fatal(err) } - within(t, 10*time.Second, "the node did not refuse a candidate taking its placement away", func() bool { + within(t, 10*time.Second, "the node did not refuse a candidate taking its assignment away", func() bool { a := node().Candidates["revert-x"] return a.Proposal == "revert-x" && a.Returns == "" && a.Verdict == nodeengine.CandidateAnswerVerdictRefused && strings.Contains(a.Refused, "composes nothing") @@ -372,30 +372,30 @@ func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValu if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil { t.Fatal(err) } - if err := placement.Delete(pkey); err != nil { + if err := assignment.Delete(pkey); err != nil { t.Fatal(err) } - within(t, 15*time.Second, "the node did not say it composes nothing once its placement went", func() bool { + within(t, 15*time.Second, "the node did not say it composes nothing once its assignment went", func() bool { b.mu.Lock() said := strings.Join(b.said, "\n") b.mu.Unlock() - return strings.Contains(said, "key in the seat's placement state is gone: it composes nothing of its own") + return strings.Contains(said, "key in the seat's assignment state is gone: it composes nothing of its own") }) time.Sleep(3 * time.Second) // what a pass in flight would mark lands within it b.mu.Lock() said := strings.Join(b.said, "\n") b.mu.Unlock() if n := node(); n.Composes != nec.ComposesOwn || strings.Contains(said, "applying the controller's compositions") { - t.Errorf("without its placement the node turned to the controller's compositions: %q", n.Composes) + t.Errorf("without its assignment the node turned to the controller's compositions: %q", n.Composes) } if h := b.have("web"); h.Composed != ran || h.Phase == nec.PhaseFailed || b.file("web") != "greeting=salut\n" { - t.Errorf("without its placement what runs moved: %+v, %q", h, b.file("web")) + t.Errorf("without its assignment what runs moved: %+v, %q", h, b.file("web")) } } // h8state is what h8 set up that the rest of H8 writes to. type h8state struct { - placement, resolved, candidates nats.KeyValue + assignment, resolved, candidates nats.KeyValue // packages is the engine's state packages (ADR 0565 §6), as the grants part creates it from the engine's manifest. packages nats.KeyValue hkey string @@ -414,10 +414,10 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { } return kv } - placement, modules, manifests := kvOf(buckets.Placement), kvOf(buckets.Modules), kvOf(buckets.Manifests) + assignment, modules, manifests := kvOf(buckets.Assignments), kvOf(buckets.Modules), kvOf(buckets.Manifests) resolved := kvOf(selfcompose.ResolvedBucket) builds := kvOf(builderbuilds.BuilderBuildsBucket) - candidates := kvOf(buckets.PlacementCandidates) + candidates := kvOf(buckets.ProposalCandidates) packages := kvOf(link.PackagesBucket) path := filepath.Join(b.dir, "web.conf") man := `{"module":"web","version":"1","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` + @@ -429,11 +429,11 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { mkey, _ := meshcatalogclient.ManifestsKey(digest) fp := fpIt("a") modv, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)}) - place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}) + place, _ := meshmoduleassignerclient.EncodeAssignments(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}) rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}}) // it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both. pkey, _ := meshmoduleassignerclient.NodeKey(itNode) - places, _ := meshmoduleassignerclient.PlacementValues(meshmoduleassigner.Placement{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}) + places, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}}) place = places[pkey] hkey, _ := nec.HaveNodeKey(itNode) // A provider on it2 serving from its own config value: a mail domain, which it1's composition settles. @@ -443,9 +443,9 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { mlraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "mailer", Manifest: mailer}) mlkey, _ := meshcatalogclient.ManifestsKey(mdigest) mlmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)}) - place2, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "it2", Modules: []meshmoduleassigner.PlacedModule{{Module: "mailer"}}}) + places2, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: "it2", Modules: []meshmoduleassigner.PlacedModule{{Module: "mailer"}}}) rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}}) - pkey2, _ := meshmoduleassignerclient.LegacyKey("it2") + pkey2, _ := meshmoduleassignerclient.NodeKey("it2") hkey2, _ := nec.HaveNodeKey("it2") buildKey, _ := builderbuilds.BuilderBuildKey("web", fp) done, _ := builderbuilds.EncodeBuilderBuild(builderbuilds.BuilderBuild{State: builderbuilds.BuildDone, Manifest: digest, @@ -460,7 +460,7 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { value []byte }{{manifests, mkey, mraw}, {modules, "web", modv}, {b.haves, hkey, rec}, {resolved, itNode + ".web.greeting", []byte(`{"value":"bonjour","source":"module"}`)}, - {manifests, mlkey, mlraw}, {modules, "mailer", mlmod}, {b.haves, hkey2, rec2}, {placement, pkey2, place2}, + {manifests, mlkey, mlraw}, {modules, "mailer", mlmod}, {b.haves, hkey2, rec2}, {assignment, pkey2, places2[pkey2]}, {assignment, "it2.mailer", places2["it2.mailer"]}, {resolved, "it2.mailer.domain", []byte(`{"value":"example.org","source":"module"}`)}, {builds, buildKey, firstBuild}} { if _, err := w.kv.Put(w.key, w.value); err != nil { t.Fatal(err) @@ -479,10 +479,10 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { if b.file("web") != "" { t.Fatal("a node the catalogue does not place composed its own") } - if _, err := placement.Put(itNode+".web", places[itNode+".web"]); err != nil { + if _, err := assignment.Put(itNode+".web", places[itNode+".web"]); err != nil { t.Fatal(err) } - if _, err := placement.Put(pkey, place); err != nil { + if _, err := assignment.Put(pkey, place); err != nil { t.Fatal(err) } if checkBuild { @@ -503,7 +503,7 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) { t.Fatal(err) } } - return stop, h8state{placement: placement, resolved: resolved, candidates: candidates, packages: packages, hkey: hkey, place: place, fp: fp} + return stop, h8state{assignment: assignment, resolved: resolved, candidates: candidates, packages: packages, hkey: hkey, place: place, fp: fp} } // h8rest is H8 once the node composes its own: the have, a config revision, and the candidate answered "same". @@ -538,13 +538,13 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str return h.Fp == fp && h.Composed != first && h.Phase == nec.PhaseRunning }) - // The node says its facts (its record, as the test wrote it), then a candidate equal to its placement is written: the + // The node says its facts (its record, as the test wrote it), then a candidate equal to its assignment is written: the // node answers "same" at the candidate's head and revision in its own key, and applies nothing. b.session.SayFacts(context.Background(), link.Report{Profile: map[string]any{"capabilities": []any{ map[string]any{"name": "service-manager", "present": true}}}}) second := b.have("web").Composed - same, _ := meshmoduleassignerclient.DecodePlacement(place) - craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Placement: &same}) + same, _ := meshmoduleassignerclient.DecodeAssignments(place) + craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Assignments: &same}) if err != nil { t.Fatal(err) } @@ -565,7 +565,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str return err == nil && answer.Proposal == "feat-x" && n.Record != nil }) if answer.Verdict != nodeengine.CandidateAnswerVerdictSame || answer.Revision != int64(crev) || len(answer.Same) != 1 || answer.Same[0] != "web" { - t.Errorf("the answer to a candidate equal to the placement: %+v", answer) + t.Errorf("the answer to a candidate equal to the assignment: %+v", answer) } if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" { t.Errorf("a candidate moved what runs: %+v, %q", h, b.file("web")) @@ -768,10 +768,10 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test } return kv } - placement, modules, manifests := kvOf(buckets.Placement), kvOf(buckets.Modules), kvOf(buckets.Manifests) + assignment, modules, manifests := kvOf(buckets.Assignments), kvOf(buckets.Modules), kvOf(buckets.Manifests) kvOf(selfcompose.ResolvedBucket) builds := kvOf(builderbuilds.BuilderBuildsBucket) - kvOf(buckets.PlacementCandidates) + kvOf(buckets.ProposalCandidates) kvOf(link.PackagesBucket) // The bus module at a build, done: its manifest written and the indexer's module naming it at fp. publish := func(fp, env string) { @@ -798,7 +798,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}}) hkey, _ := nec.HaveNodeKey(itNode) - places, _ := meshmoduleassignerclient.PlacementValues(meshmoduleassigner.Placement{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "nats"}}}) + places, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "nats"}}}) // The have the engine wrote of the folded bus: the old-form build, no fingerprint. natsHave, _ := nec.HaveKey(itNode, "nats") oldHave, _ := nec.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1792674595610176744", Phase: nec.PhaseRunning, Health: nec.HealthOK}) @@ -848,7 +848,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test stop := b.start() defer stop() for key, value := range places { - if _, err := placement.Put(key, value); err != nil { + if _, err := assignment.Put(key, value); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-host/trust.go b/cmd/mesh-host/trust.go index 405b3e7..65d3118 100644 --- a/cmd/mesh-host/trust.go +++ b/cmd/mesh-host/trust.go @@ -7,11 +7,11 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "github.com/novox/mesh-host/internal/buckets" "os" @@ -29,7 +29,7 @@ import ( // **What this engine trusts a mesh signature from** (novox/hq ADR 0550 §2; design 47's L3 item d; issue 1126): the // vault's key alone, read from the vault's state `signers`, which only the vault may write: the bus grants are its -// anchor, as the wants' and the placement's. The controller's key, which the node was enrolled with (its membership's +// anchor, as the wants' and the assignment's. The controller's key, which the node was enrolled with (its membership's // signer), is trusted never: the controller ended by its unassignment (the operator, 2026-11-02) and its lease is never // handed, so no gate waits on it. What it trusts now the node states in its record (NodeRecord.Trusts). type meshTrust struct { @@ -180,20 +180,17 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s } // placedAllHave says whether every module the seat mesh-module-assigner places on node has a have of its own, read from the -// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose placement or haves +// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose assignment or haves // cannot be read, keeps reading it. func placedAllHave(ctx context.Context, session *link.Session, node string) (bool, string) { key, err := meshmoduleassignerclient.NodeKey(node) if err != nil { return false, err.Error() } - legacy, _ := meshmoduleassignerclient.LegacyKey(node) - // Its node key names every module placed (research 072 row 37); before the holder converted it, its legacy key does. + // Its node key names every module placed (research 072 row 37). values := map[string][]byte{} - for _, k := range []string{key, legacy} { - if h, err := session.KVHistory(ctx, buckets.Placement, k); err == nil && len(h) > 0 && !h[len(h)-1].Deleted { - values[k] = h[len(h)-1].Value - } + if h, err := session.KVHistory(ctx, buckets.Assignments, key); err == nil && len(h) > 0 && !h[len(h)-1].Deleted { + values[key] = h[len(h)-1].Value } p, err := placedModules(node, values) if err != nil { @@ -204,27 +201,23 @@ func placedAllHave(ctx context.Context, session *link.Session, node string) (boo }) } -// placedModules is the modules node's node key names (a placement of their names alone), or its legacy key's placement -// where it has no node key; an error when neither reads. -func placedModules(node string, values map[string][]byte) (meshmoduleassigner.Placement, error) { +// placedModules is the modules node's node key names (an assignment of their names alone); an error when it does not +// read. +func placedModules(node string, values map[string][]byte) (meshmoduleassigner.Assignments, error) { key, _ := meshmoduleassignerclient.NodeKey(node) - if raw, ok := values[key]; ok { - n, err := meshmoduleassignerclient.DecodeNode(raw) - if err != nil { - return meshmoduleassigner.Placement{}, err - } - p := meshmoduleassigner.Placement{Node: node} - for _, r := range n.Modules { - p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: r.Module}) - } - return p, nil - } - legacy, _ := meshmoduleassignerclient.LegacyKey(node) - raw, ok := values[legacy] + raw, ok := values[key] if !ok { - return meshmoduleassigner.Placement{}, errors.New("its placement is not read") + return meshmoduleassigner.Assignments{}, errors.New("its assignment is not read") } - return meshmoduleassignerclient.DecodePlacement(raw) + n, err := meshmoduleassignerclient.DecodeNode(raw) + if err != nil { + return meshmoduleassigner.Assignments{}, err + } + p := meshmoduleassigner.Assignments{Node: node} + for _, r := range n.Modules { + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: r.Module}) + } + return p, nil } // allHave says whether has finds, for every module p places, a have that names a fingerprint of the new form, and why @@ -232,7 +225,7 @@ func placedModules(node string, values map[string][]byte) (meshmoduleassigner.Pl // declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to // remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own // apply refused from then on). -func allHave(p meshmoduleassigner.Placement, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) { +func allHave(p meshmoduleassigner.Assignments, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) { if len(p.Modules) == 0 { return false, "the seat mesh-module-assigner places no module here" } diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 79bbda2..fbead93 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -6,8 +6,8 @@ import ( "encoding/json" "errors" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" engcomp "github.com/novox/mesh-host/internal/compositions" "path/filepath" @@ -120,7 +120,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 { t.Fatalf("before the read is retired: %+v, %v", d, err) } - placed := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} + placed := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} // Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the // node's own) keeps the kept declaration read, since it is the one record of what runs of it. newForm := nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning} @@ -139,7 +139,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { } } // The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774). - withWireguard := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}} + withWireguard := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}} if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { if m == overlay.Name { return oldForm, true, nil @@ -148,7 +148,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { }); !got { t.Errorf("%s without a fingerprint beside zsh at the new form keeps the read: %s", overlay.Name, why) } - if got, _ := allHave(meshmoduleassigner.Placement{Node: "n1"}, nil); got { + if got, _ := allHave(meshmoduleassigner.Assignments{Node: "n1"}, nil); got { t.Error("a node with nothing placed retires the read") } keptRetired.Store(true) diff --git a/go.mod b/go.mod index 70226eb..295c246 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,9 @@ require ( git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 80c13cb..d1e8bb5 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -19,7 +19,7 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "github.com/novox/mesh-host/internal/compositions" "slices" "sort" diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 36d826c..0b3738d 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -6,8 +6,8 @@ import ( "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets/bucketstest" "github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/judge" diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 5835bc5..16fd928 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -183,7 +183,7 @@ type Options struct { // SDKSource is where the mesh's shared library is built from. It is published to the package // registry before the base is built, because the base resolves it by version (novox/hq ADR 0076). SDKSource Source - // Site is where this machine sits, for the private network's placement. + // Site is where this machine sits, for the private network's assignment. Site string // Answers are the choices, answered by flag: name → answer. What a terminal would be asked. Answers map[string]string diff --git a/internal/bootstrap/phase2.go b/internal/bootstrap/phase2.go index e49eb1b..dba8989 100644 --- a/internal/bootstrap/phase2.go +++ b/internal/bootstrap/phase2.go @@ -146,7 +146,7 @@ func InstallFromCatalogue(ctx context.Context, o Options, control controlPlane, // // **Assigning is not being on the network** — a lesson paid for: the module installed, the names // file was written with no names in it, and everything reported success, because nobody had said -// where this machine IS. So placement is part of the step, not a separate act. +// where this machine IS. So assignment is part of the step, not a separate act. func PlaceOnTheNetwork(ctx context.Context, o Options, control controlPlane, brokerAddress string, say func(string)) error { diff --git a/internal/buckets/buckets.go b/internal/buckets/buckets.go index c83279d..d495046 100644 --- a/internal/buckets/buckets.go +++ b/internal/buckets/buckets.go @@ -8,9 +8,9 @@ import ( vault "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" - seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" - wants "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + wants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-sdk/go/keys" ) @@ -27,13 +27,13 @@ const ( // HaveHistory is the number of revisions the have bucket keeps per key. const HaveHistory = 20 -// The states of the placement seat's holder and of the wants seat's holder that the engine reads, named by the holder's +// The states of the assigner seat's holder and of the wants seat's holder that the engine reads, named by the holder's // module as every state is (ADR 0634 §1, finding F4: the holder's name is kept here, in the engine's code). const ( - // Placement holds a node's placement, `.node`, and each of its modules, `.`. - Placement = AssignerHolder + "_" + seat.StatePlacement - // PlacementCandidates holds a node's candidate of a proposal, `.`. - PlacementCandidates = AssignerHolder + "_" + seat.StateCandidates + // Assignments holds a node's assignments, `.node`, and each of its modules, `.`. + Assignments = AssignerHolder + "_" + seat.StateAssignments + // ProposalCandidates holds a node's candidate of a proposal, `.`. + ProposalCandidates = AssignerHolder + "_" + seat.StateCandidates // Wants holds what each node should run, `want..` and `want..node`. Wants = AssignerHolder + "_" + wants.StateWants diff --git a/internal/buckets/buckets_test.go b/internal/buckets/buckets_test.go index 4700d39..0fd64c3 100644 --- a/internal/buckets/buckets_test.go +++ b/internal/buckets/buckets_test.go @@ -59,7 +59,7 @@ func TestACandidateKeyEscapesTheGroup(t *testing.T) { // holder (ADR 0625 §4), so these rows are the one place the node-engine's code says them. func TestTheHoldersBucketsAreTheirStatesBuckets(t *testing.T) { for name, c := range map[string][2]string{ - "Placement": {Placement, "mesh-delivery-assigner_placement"}, "PlacementCandidates": {PlacementCandidates, "mesh-delivery-assigner_candidates"}, + "Assignments": {Assignments, "mesh-delivery-assigner_assignments"}, "ProposalCandidates": {ProposalCandidates, "mesh-delivery-assigner_candidates"}, "Wants": {Wants, "mesh-delivery-assigner_wants"}, "Modules": {Modules, "mesh-delivery-indexer_modules"}, "Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, "Verdicts": {Verdicts, "mesh-delivery-indexer_verdicts"}, "Candidates": {Candidates, "mesh-delivery-indexer_candidates"}, "Provisions": {Provisions, "mesh-provisioner_provisions"}, diff --git a/internal/buckets/bucketstest/bucketstest.go b/internal/buckets/bucketstest/bucketstest.go index 03912cc..b849932 100644 --- a/internal/buckets/bucketstest/bucketstest.go +++ b/internal/buckets/bucketstest/bucketstest.go @@ -1,4 +1,4 @@ -// Package bucketstest writes the values of the states the engine only reads (a want, a placement candidate) as their +// Package bucketstest writes the values of the states the engine only reads (a want, a assignment candidate) as their // holders do, for the tests that stand in for the holders. package bucketstest @@ -7,8 +7,8 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" ) // Version is the value version the holders write. @@ -26,13 +26,13 @@ func NodeWant(w meshwants.WantsByWantNodeNodeValue) ([]byte, error) { return json.Marshal(w) } -// Candidate is a node's candidate of a proposal as the placement seat's holder writes it. +// Candidate is a node's candidate of a proposal as the assignment seat's holder writes it. func Candidate(c meshmoduleassigner.CandidatesByProposalNodeValue) ([]byte, error) { c.V = Version - if c.Placement != nil { - p := *c.Placement + if c.Assignments != nil { + p := *c.Assignments p.V = Version - c.Placement = &p + c.Assignments = &p } return json.Marshal(c) } @@ -48,7 +48,7 @@ func Pull(head meshdeliveryclient.Head, modules []string, fps map[string]meshcat return meshcatalog.CandidatesByGroupValuePullsItem{Repository: head.Repository, Number: int64(head.Number), Head: head.Head, Modules: modules, Fps: fps} } -// Raw is each value as JSON, as a composer case carries verdicts and placements. +// Raw is each value as JSON, as a composer case carries verdicts and assignments. func Raw[V any](in map[string]V) map[string]json.RawMessage { out := map[string]json.RawMessage{} for k, v := range in { diff --git a/internal/compose/composed.go b/internal/compose/composed.go index f7fe9ad..e976e04 100644 --- a/internal/compose/composed.go +++ b/internal/compose/composed.go @@ -50,7 +50,7 @@ func CompositionsApplied(in Input, node string, fps map[string]string) (map[stri } // CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a -// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module +// assignment candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module // the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted, // the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a // module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes diff --git a/internal/compose/credentials.go b/internal/compose/credentials.go index e05339c..8c429ed 100644 --- a/internal/compose/credentials.go +++ b/internal/compose/credentials.go @@ -351,7 +351,7 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c if !held && mode == Foreseeing { // Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in, // named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is - // foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written + // foreseen, never refused: the vault mints it from the grants part's wanted list once the assignment is written // on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it // (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused). if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil { diff --git a/internal/compose/credentials_test.go b/internal/compose/credentials_test.go index 50ddfc6..47b5671 100644 --- a/internal/compose/credentials_test.go +++ b/internal/compose/credentials_test.go @@ -118,7 +118,7 @@ func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) { // 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the // controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault -// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused +// mints it once the assignment is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused // "has no bus credential: nothing was issued for ace.mesh-self-check"). func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) { broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check", diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go index a3b9093..702f1cb 100644 --- a/internal/compose/frombus/answer.go +++ b/internal/compose/frombus/answer.go @@ -18,8 +18,8 @@ import ( "github.com/novox/mesh-host/internal/compose/resolution" ) -// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with -// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as +// Answer is a node's answer to its proposal's assignment candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with +// s.Assignments laid over the trunk's assignment (compose.CompositionsShadow, its own sealed values and boxes through r as // for its own composition), compared with what it runs (compositions.CompareComposed). It applies nothing and writes // nothing; the proposal and the revision are the candidate's, said back in the answer. // @@ -35,9 +35,9 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st a.Verdict = nodeengine.CandidateAnswerVerdictRefused return a } - trunk, err := r.Placements(ctx) + trunk, err := r.Assignments(ctx) if err != nil { - return refuse(fmt.Errorf("frombus: placement: %w", err)) + return refuse(fmt.Errorf("frombus: assignment: %w", err)) } if s.Running == nil { s.Running = running @@ -144,7 +144,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node. added := map[string]bool{} - for node, p := range s.Placements { + for node, p := range s.Assignments { if p == nil { continue } @@ -430,7 +430,7 @@ const ( leaveOwn = "own-secrets" ) -// assignedOn is the modules placed on node, in placement order. +// assignedOn is the modules placed on node, in assignment order. func assignedOn(in compose.Input, node string) []string { for _, n := range in.Nodes { if n.Name == node { @@ -454,7 +454,7 @@ func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool } // culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the -// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave +// first, in assignment order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave // ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's // routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its // stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 261f490..6251e35 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" @@ -41,11 +41,11 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } mk := func(placed ...string) *bus { - p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} + p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} for _, m := range placed { p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m}) } - b := &bus{placements: map[string]meshmoduleassigner.Placement{"n1": p}, + b := &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, modules: map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}}, manifests: map[string][]byte{meshcatalogclient.ManifestDigest(lemurs): lemurs}, @@ -69,14 +69,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } return b } - given := func(b *bus) map[string]*meshmoduleassigner.Placement { - p := b.placements["n1"] - return map[string]*meshmoduleassigner.Placement{"n1": &p} + given := func(b *bus) map[string]*meshmoduleassigner.Assignments { + p := b.assignments["n1"] + return map[string]*meshmoduleassigner.Assignments{"n1": &p} } // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named. b := mk("lemurs", "web", "api", "spiegel") - a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)}, + a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b)}, map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) var refused []string for _, r := range a.Refusals { @@ -100,13 +100,13 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every // refused module held today the verdict is no refusal, and each is named under Held. - today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, + today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) if today.Verdict == nodeengine.CandidateAnswerVerdictRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { t.Errorf("held as today: %+v", today) } // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. - part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}}, + part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held"}}, map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) if part.Verdict != nodeengine.CandidateAnswerVerdictRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { t.Errorf("held as today in part: %+v", part) @@ -123,9 +123,9 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. b = mk("lemurs", overlay.Name, "spiegel") - p := b.placements["n1"] + p := b.assignments["n1"] p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p + b.assignments["n1"] = p fps := map[string]string{"lemurs": fp("e")} stub := Scope{Node: "n1"} in, err := Input(ctx, b, Mesh{}, stub) @@ -169,14 +169,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if string(gotOwn) != string(wantOwn) { t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn) } - a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3) + a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3) if a.Verdict != nodeengine.CandidateAnswerVerdictSame { t.Errorf("the fold's answer: %+v, want the same", a) } // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). keptToo := append(append([]engcomp.Composed{}, running...), engcomp.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame || len(a.Removed) != 0 { + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame || len(a.Removed) != 0 { t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) } // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed @@ -189,7 +189,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { } withMembership = append(withMembership, c) } - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame { + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame { t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) } // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. @@ -202,9 +202,9 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { // "not built for this mesh". { bb := mk("lemurs", overlay.Name, "spiegel") - bp := bb.placements["n1"] + bp := bb.assignments["n1"] bp.Decided.Endpoint = "n1.example:51820" - bb.placements["n1"] = bp + bb.assignments["n1"] = bp sp := bb.modules["spiegel"] sp.Fp2 = fp("3") bb.modules["spiegel"] = sp @@ -237,14 +237,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } - p = b.placements["n1"] + p = b.assignments["n1"] p.Decided.Endpoint = "n1.example:51820" for i := range p.Modules { if p.Modules[i].Module == "spiegel" { p.Modules[i].Ports = map[string]int64{"8080": 20005} } } - b.placements["n1"] = p + b.assignments["n1"] = p rec := b.records["n1"] rec.OutwardLinks = []string{"eth0"} b.records["n1"] = rec @@ -375,11 +375,11 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } - p = b.placements["n1"] + p = b.assignments["n1"] p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p + b.assignments["n1"] = p fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")} - a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3) + a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3) refused = nil for _, r := range a.Refusals { refused = append(refused, r.Module) @@ -475,14 +475,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } - p = b.placements["n1"] + p = b.assignments["n1"] p.Decided.Endpoint = "n1.example:51820" for i := range p.Modules { if p.Modules[i].Module == "site" { p.Modules[i].Ports = map[string]int64{"8080": 20005} } } - b.placements["n1"] = p + b.assignments["n1"] = p rec = b.records["n1"] rec.OutwardLinks = []string{"eth0"} b.records["n1"] = rec @@ -516,14 +516,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { b2.manifests[meshcatalogclient.ManifestDigest(raw)] = raw b2.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)} } - p2 := b2.placements["n1"] + p2 := b2.assignments["n1"] p2.Decided.Endpoint = "n1.example:51820" for i := range p2.Modules { if p2.Modules[i].Module == "site" { p2.Modules[i].Ports = map[string]int64{"8080": 20005} } } - b2.placements["n1"] = p2 + b2.assignments["n1"] = p2 b2.records["n1"] = rec b2.held = true fps["app"] = fp("9") @@ -561,7 +561,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { for i := range p.Modules { p.Modules[i].Ports = nil } - b.placements["n1"] = p + b.assignments["n1"] = p siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) siteRuns.FP = fp("7") b.manifests[meshcatalogclient.ManifestDigest(site)] = site @@ -591,18 +591,18 @@ func otherNodeNotResolvedIsNamed(t *testing.T) { prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`) cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`) // n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here. - b := &bus{placements: map[string]meshmoduleassigner.Placement{ + b := &bus{assignments: map[string]meshmoduleassigner.Assignments{ "n1": {Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, "n2": {Node: "n2", Decided: meshmoduleassigner.NodeDecided{Address: "10.77.0.2"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, modules: map[string]meshcatalog.ModulesByModuleValue{"prov": {Manifest: meshcatalogclient.ManifestDigest(prov)}, "cons": {Manifest: meshcatalogclient.ManifestDigest(cons)}}, manifests: map[string][]byte{meshcatalogclient.ManifestDigest(prov): prov, meshcatalogclient.ManifestDigest(cons): cons}} - given := map[string]*meshmoduleassigner.Placement{} - for n, p := range b.placements { + given := map[string]*meshmoduleassigner.Assignments{} + for n, p := range b.assignments { p := p given[n] = &p } - a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1) + a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1) if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { t.Errorf("a refusal for want of n2's provider = %+v", a) } diff --git a/internal/compose/frombus/export.go b/internal/compose/frombus/export.go index 7d82bf1..bd0ed75 100644 --- a/internal/compose/frombus/export.go +++ b/internal/compose/frombus/export.go @@ -2,7 +2,7 @@ package frombus import ( "fmt" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" "sort" "strconv" @@ -11,12 +11,12 @@ import ( "github.com/novox/mesh-host/internal/compose" ) -// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567 -// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export` +// Export is one node's assignment as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567 +// §7): the inverse of Input for what a person decides, so the controller's one read-only export verb // answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay // stable for the same store, since adopt checks its digest again at the warrant. // What the machine observes (its record) is not in it. -func Export(in compose.Input, node string) (meshmoduleassigner.Placement, error) { +func Export(in compose.Input, node string) (meshmoduleassigner.Assignments, error) { var n *compose.Node for i := range in.Nodes { if in.Nodes[i].Name == node { @@ -24,11 +24,11 @@ func Export(in compose.Input, node string) (meshmoduleassigner.Placement, error) } } if n == nil { - return meshmoduleassigner.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) + return meshmoduleassigner.Assignments{}, fmt.Errorf("frombus: the input has no node %q", node) } - // At the version a placement key is read at, with a list of modules even when none is placed: the export is the + // At the version a assignment key is read at, with a list of modules even when none is placed: the export is the // seed's value, and the reader refuses a value of version 0 or without its modules. - p := meshmoduleassigner.Placement{V: sdkkeys.Version, Node: node, Modules: []meshmoduleassigner.PlacedModule{}, Decided: meshmoduleassigner.NodeDecided{ + p := meshmoduleassigner.Assignments{V: sdkkeys.Version, Node: node, Modules: []meshmoduleassigner.PlacedModule{}, Decided: meshmoduleassigner.NodeDecided{ Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, AgentAccountHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} @@ -66,7 +66,7 @@ func Export(in compose.Input, node string) (meshmoduleassigner.Placement, error) m.Bindings[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module} } // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with - // the placement. A module in the config form has mesh-config's values laid into its layer, which are not. + // the assignment. A module in the config form has mesh-config's values laid into its layer, which are not. settingsForm := !in.Catalogue[module].DeclaresConfig() for _, layer := range n.Settings[module] { if layer.From == catalogue.MeshWideLayer { diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index a7cc735..f83a051 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -7,13 +7,13 @@ // through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads. // // **What it reads, and from whom.** -// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the -// seat mesh-module-assigner's state `placement`, one key per node (ADR 0567 §1); +// - which modules run on each node, with their pins, holdings, host ports, bindings and settings assignment: the +// seat mesh-module-assigner's state `assignment`, one key per node (ADR 0567 §1); // - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and -// accounts): the same placement key, Placement.Decided; +// accounts): the same assignment key, Assignments.Decided; // - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the // node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided, -// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; +// in its assignment, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; // - every module's manifest: the indexer's `modules.` and its manifests by digest; for each module placed on // the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint // the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule @@ -41,10 +41,10 @@ import ( "sort" "strings" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" @@ -62,8 +62,8 @@ import ( // Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a // read that failed, never an absent key (an absent key is an empty answer). type Reader interface { - // Placements is every node's placement, by node (buckets.Placement). - Placements(ctx context.Context) (map[string]meshmoduleassigner.Placement, error) + // Assignments is every node's assignment, by node (buckets.Assignments). + Assignments(ctx context.Context) (map[string]meshmoduleassigner.Assignments, error) // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of // buckets.Have (ADR 0555 §3). Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error) @@ -137,11 +137,11 @@ type Mesh struct { // controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource // naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done // build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer -// read it: the check of every node judges placement, and reads no archive. +// read it: the check of every node judges assignment, and reads no archive. // -// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates +// Assignments, when set, stands in for the assignment of the nodes it names: a proposal's candidates // (meshmoduleassigner.CandidatesByProposalNodeValue), so a node composes in shadow with a proposal laid over and compares the result with -// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always. +// what it runs (ADR 0567 §3); a nil value removes the node's assignment. The rest is read as always. // // InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again // (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126). @@ -150,12 +150,12 @@ type Mesh struct { // (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried // as it runs (issue 1126). Nil composes every folded module from its manifest. type Scope struct { - Node string - Every bool - KeptAt map[string]string - Placements map[string]*meshmoduleassigner.Placement - InOwn []string - Running []engcomp.Composed + Node string + Every bool + KeptAt map[string]string + Assignments map[string]*meshmoduleassigner.Assignments + InOwn []string + Running []engcomp.Composed // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). @@ -174,7 +174,7 @@ type Scope struct { // controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module // run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own // composition keeps them as it runs them until a want names a build of the new form for one. -func InOwn(running []engcomp.Composed, p meshmoduleassigner.Placement) []string { +func InOwn(running []engcomp.Composed, p meshmoduleassigner.Assignments) []string { placed := map[string]bool{} for _, m := range p.Modules { placed[m.Module] = true @@ -258,10 +258,10 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module // base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once // for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice). type base struct { - in compose.Input - placements map[string]meshmoduleassigner.Placement - records map[string]nodeengine.NodeRecord - modules map[string]meshcatalog.ModulesByModuleValue + in compose.Input + assignments map[string]meshmoduleassigner.Assignments + records map[string]nodeengine.NodeRecord + modules map[string]meshcatalog.ModulesByModuleValue // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order // read: every scope holds it. ownRefused [][2]string @@ -291,29 +291,29 @@ func (b base) cloned() compose.Input { return in } -// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values. +// readBase is input up to the builds: assignment, records, the indexer's modules and manifests, each node's values. func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { var b base - placements, err := r.Placements(ctx) + assignments, err := r.Assignments(ctx) if err != nil { - return b, fmt.Errorf("frombus: placement: %w", err) + return b, fmt.Errorf("frombus: assignment: %w", err) } - if len(s.Placements) > 0 { - given := make(map[string]meshmoduleassigner.Placement, len(placements)+len(s.Placements)) - for node, p := range placements { + if len(s.Assignments) > 0 { + given := make(map[string]meshmoduleassigner.Assignments, len(assignments)+len(s.Assignments)) + for node, p := range assignments { given[node] = p } - for node, p := range s.Placements { + for node, p := range s.Assignments { if p == nil { delete(given, node) continue } if p.Node != node { - return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) + return b, fmt.Errorf("frombus: the assignment given for %s names the node %q", node, p.Node) } given[node] = *p } - placements = given + assignments = given } records, err := r.Records(ctx) if err != nil { @@ -323,8 +323,8 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { if err != nil { return b, fmt.Errorf("frombus: the indexer's modules: %w", err) } - if _, ok := placements[s.Node]; !ok && !s.Every { - return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node) + if _, ok := assignments[s.Node]; !ok && !s.Every { + return b, fmt.Errorf("frombus: %s has no assignment in the catalogue", s.Node) } in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority, @@ -357,7 +357,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { (*field)[module] = why } ownPlaced := map[string]bool{} - for _, pm := range placements[s.Node].Modules { + for _, pm := range assignments[s.Node].Modules { ownPlaced[pm.Module] = true } for _, name := range sortedKeys(modules) { @@ -401,9 +401,9 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { } var missing []string - for _, node := range nodesOf(placements, records) { + for _, node := range nodesOf(assignments, records) { rec, hasRecord := records[node] - p := placements[node] + p := assignments[node] if !hasRecord { if len(p.Modules) > 0 { missing = append(missing, node) @@ -492,13 +492,13 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { } } if in.Adopted != nil && in.Adopted.Adopted { - in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records) + in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, assignments[in.Adopted.Hub].Decided.PeerNames, records) } if len(missing) > 0 { return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet", strings.Join(missing, ", ")) } - b.in, b.placements, b.records, b.modules = in, placements, records, modules + b.in, b.assignments, b.records, b.modules = in, assignments, records, modules return b, nil } @@ -523,7 +523,7 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte if _, ok := b.records[s.Node]; !ok { return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) } - if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil { + if err := fromBuilds(ctx, r, build, &in, s, b.assignments[s.Node], b.modules, read, parse, refuse, wanted); err != nil { return compose.Input{}, err } // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). @@ -799,7 +799,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, // by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it // (issue 1126). func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error), - in *compose.Input, s Scope, p meshmoduleassigner.Placement, modules map[string]meshcatalog.ModulesByModuleValue, + in *compose.Input, s Scope, p meshmoduleassigner.Assignments, modules map[string]meshcatalog.ModulesByModuleValue, read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), refuse func(module string, err error), wanted map[string]string) error { // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides @@ -1004,9 +1004,9 @@ func attachedOf(in []nodeengine.AttachedNetwork) []catalogue.AttachedNetwork { return out } -// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person +// nodeOf is the composition's node from its record (what the machine observes) and its assignment (what a person // decided of it, and where its modules run). -func nodeOf(rec nodeengine.NodeRecord, p meshmoduleassigner.Placement) compose.Node { +func nodeOf(rec nodeengine.NodeRecord, p meshmoduleassigner.Assignments) compose.Node { n := compose.Node{ Name: rec.Node, Place: compose.Place{Key: rec.TunnelKey}, @@ -1024,9 +1024,9 @@ func nodeOf(rec nodeengine.NodeRecord, p meshmoduleassigner.Placement) compose.N return n } -// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes +// placeNode sets what a person decided of n and where its modules run, from its assignment; what the machine observes // (its keys, links, networks, certificate) and its settings layers are left as they are. -func placeNode(n *compose.Node, p meshmoduleassigner.Placement) { +func placeNode(n *compose.Node, p meshmoduleassigner.Assignments) { d := p.Decided n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address} n.Assigned, n.PublicDomain = meshmoduleassignerclient.Assigned(p), d.PublicDomain @@ -1035,15 +1035,15 @@ func placeNode(n *compose.Node, p meshmoduleassigner.Placement) { n.Pins, n.Bound, n.Ports = meshmoduleassignerclient.ChosenPins(p), meshmoduleassignerclient.Bound(p), meshmoduleassignerclient.HostPorts(p) } -// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's -// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its +// Placed is in with the assignments given standing in for those of the nodes they name (issue 1126): each such node's +// decided parts, modules, pins, bindings, host ports, settings assignment and holdings are the given assignment's, its // config values those in already carries for it (the layer of the node's own name), and what the machine observes is -// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has +// kept. A nil assignment leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has // no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given -// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's -// Placements). -func Placed(in compose.Input, placements map[string]*meshmoduleassigner.Placement) (compose.Input, error) { - if len(placements) == 0 { +// assignment. It is how a check composes a change's own assignment over an input already filled (composer.Case's +// Assignments). +func Placed(in compose.Input, assignments map[string]*meshmoduleassigner.Assignments) (compose.Input, error) { + if len(assignments) == 0 { return in, nil } out := in @@ -1052,17 +1052,17 @@ func Placed(in compose.Input, placements map[string]*meshmoduleassigner.Placemen for i, n := range out.Nodes { index[n.Name] = i } - for _, node := range sortedKeys(placements) { - p := placements[node] + for _, node := range sortedKeys(assignments) { + p := assignments[node] i, ok := index[node] if !ok { - return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node) + return compose.Input{}, fmt.Errorf("frombus: a assignment is given for %s, which the input holds no node for", node) } if p == nil { - p = &meshmoduleassigner.Placement{Node: node} + p = &meshmoduleassigner.Assignments{Node: node} } if p.Node != node { - return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) + return compose.Input{}, fmt.Errorf("frombus: the assignment given for %s names the node %q", node, p.Node) } n := out.Nodes[i] placeNode(&n, *p) @@ -1086,12 +1086,12 @@ func Placed(in compose.Input, placements map[string]*meshmoduleassigner.Placemen } out.Holdings = nil for _, h := range in.Holdings { - if _, given := placements[h.Node]; !given { + if _, given := assignments[h.Node]; !given { out.Holdings = append(out.Holdings, h) } } - for _, node := range sortedKeys(placements) { - if p := placements[node]; p != nil { + for _, node := range sortedKeys(assignments) { + if p := assignments[node]; p != nil { out.Holdings = append(out.Holdings, meshmoduleassignerclient.Held(*p)...) } } @@ -1132,10 +1132,10 @@ func carriedPeers(t compose.Tunnel, names map[string]string, records map[string] return out } -// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. -func nodesOf(placements map[string]meshmoduleassigner.Placement, records map[string]nodeengine.NodeRecord) []string { +// nodesOf is every node named by a assignment or a record, by name: the order the controller reads them in. +func nodesOf(assignments map[string]meshmoduleassigner.Assignments, records map[string]nodeengine.NodeRecord) []string { seen := map[string]bool{} - for n := range placements { + for n := range assignments { seen[n] = true } for n := range records { diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index c0a4cae..6ae853e 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -11,10 +11,10 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" @@ -35,7 +35,7 @@ import ( type bus struct { vaultAuthority string handed map[string]bool - placements map[string]meshmoduleassigner.Placement + assignments map[string]meshmoduleassigner.Assignments records map[string]nodeengine.NodeRecord modules map[string]meshcatalog.ModulesByModuleValue manifests map[string][]byte @@ -44,7 +44,7 @@ type bus struct { own []compose.OwnSecretRow ownErr error // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming - // module its placement does not put on the node. + // module its assignment does not put on the node. asked []compose.CredentialKey // builds are the builder's keys, by " "; archives the artifact store's blobs, by digest, and fetched // every URL asked; served another node's values by ".", and servedRead every ask with its keys. @@ -83,8 +83,8 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map return out, nil } -func (b *bus) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { - return b.placements, nil +func (b *bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) { + return b.assignments, nil } func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } @@ -109,7 +109,7 @@ func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider b.asked = append(b.asked, asConsumer...) for _, k := range asConsumer { placed := false - for _, m := range b.placements[node].Modules { + for _, m := range b.assignments[node].Modules { placed = placed || m.Module == k.ConsumerModule } if !placed { @@ -143,7 +143,7 @@ func publicKey(t *testing.T) string { return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) } -// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the +// Issue 1126 (S3, S4, S5): the bus's assignment, node records, manifests and config values fill the same compose.Input the // controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input // composes; a node reads only its own config values; what cannot be composed is refused, naming it. func TestInput(t *testing.T) { @@ -167,7 +167,7 @@ func TestInput(t *testing.T) { } digest := meshcatalogclient.ManifestDigest(xterm) // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its - // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. + // layers, not mesh-config, so the assignment carries them (MeshValues, Values) and mesh-config's are not read for it. media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` + `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) @@ -178,7 +178,7 @@ func TestInput(t *testing.T) { Peers: []meshmoduleassigner.FoundTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} mk := func() *bus { return &bus{ - placements: map[string]meshmoduleassigner.Placement{ + assignments: map[string]meshmoduleassigner.Assignments{ "anchor": {Node: "anchor", Decided: meshmoduleassigner.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}}, Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name}}, @@ -289,15 +289,15 @@ func TestInput(t *testing.T) { t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore)) } - // Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one + // Issue 1126: a candidate assignment equal to the node's composes what it runs, and CompareComposed says so; one // thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves - // here. Placed puts the same placement over the store's input unchanged. + // here. Placed puts the same assignment over the store's input unchanged. running := make([]compositions.Composed, 0, len(fromBus)) for _, c := range fromBus { running = append(running, c) } - laptop := mk().placements["laptop"] - asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*meshmoduleassigner.Placement{"laptop": &laptop}}, fps) + laptop := mk().assignments["laptop"] + asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}}, fps) if err == nil && len(held) > 0 { err = fmt.Errorf("modules held: %v", held) } @@ -305,11 +305,11 @@ func TestInput(t *testing.T) { t.Fatal(err) } if c := compositions.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { - t.Errorf("the candidate equal to the placement: %+v", c) + t.Errorf("the candidate equal to the assignment: %+v", c) } - moved := mk().placements["anchor"] + moved := mk().assignments["anchor"] moved.Decided.Endpoint = "anchor.example:51821" - changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*meshmoduleassigner.Placement{"laptop": &laptop, "anchor": &moved}}, fps) + changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop, "anchor": &moved}}, fps) if err == nil && len(held) > 0 { err = fmt.Errorf("modules held: %v", held) } @@ -335,11 +335,11 @@ func TestInput(t *testing.T) { files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}}) one := func(placed ...string) *bus { - p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} + p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} for _, m := range placed { p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m}) } - return &bus{placements: map[string]meshmoduleassigner.Placement{"n1": p}, + return &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: map[string]meshcatalog.ModulesByModuleValue{"keeper": {Manifest: meshcatalogclient.ManifestDigest(keeper)}, "lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}, "objects": {Manifest: meshcatalogclient.ManifestDigest(objects)}, "files": {Manifest: meshcatalogclient.ManifestDigest(files)}}, @@ -385,14 +385,14 @@ func TestInput(t *testing.T) { // node's own, each in its place, its module taken off; the rest split by module. tunnelled := func() *bus { b := one("keeper", "lemurs", overlay.Name) - p := b.placements["n1"] + p := b.assignments["n1"] p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p + b.assignments["n1"] = p return b } - tunnelledAs := func() map[string]*meshmoduleassigner.Placement { - p := tunnelled().placements["n1"] - return map[string]*meshmoduleassigner.Placement{"n1": &p} + tunnelledAs := func() map[string]*meshmoduleassigner.Assignments { + p := tunnelled().assignments["n1"] + return map[string]*meshmoduleassigner.Assignments{"n1": &p} } folded := func() []compositions.Composed { b := tunnelled() @@ -437,16 +437,16 @@ func TestInput(t *testing.T) { } return out }() - if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) { + if got := InOwn(folded, tunnelled().assignments["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) { t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name) } - placedAs := func(modules ...string) map[string]*meshmoduleassigner.Placement { - p := one(modules...).placements["n1"] - return map[string]*meshmoduleassigner.Placement{"n1": &p} + placedAs := func(modules ...string) map[string]*meshmoduleassigner.Assignments { + p := one(modules...).assignments["n1"] + return map[string]*meshmoduleassigner.Assignments{"n1": &p} } for name, c := range map[string]struct { b *bus - given map[string]*meshmoduleassigner.Placement + given map[string]*meshmoduleassigner.Assignments running []compositions.Composed verdict string check func(nodeengine.CandidateAnswer) bool @@ -483,7 +483,7 @@ func TestInput(t *testing.T) { runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool { return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 }}, - "cannot compose": {one("keeper"), map[string]*meshmoduleassigner.Placement{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused, + "cannot compose": {one("keeper"), map[string]*meshmoduleassigner.Assignments{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool { return a.Refused != "" }}, // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want // names and the controller folds into the node's own, are folded there again: the node's own is the same, and @@ -495,7 +495,7 @@ func TestInput(t *testing.T) { if name == "mesh-wireguard and an old-form module, folded" { fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network } - a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7) + a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Assignments: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7) if a.Verdict != c.verdict || !c.check(a) { t.Errorf("%s: %+v", name, a) } @@ -503,28 +503,28 @@ func TestInput(t *testing.T) { t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) } } - if got, err := Placed(want, map[string]*meshmoduleassigner.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { - t.Errorf("Placed with the node's own placement changes the input: %v", err) + if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("Placed with the node's own assignment changes the input: %v", err) } - if got, err := Placed(want, map[string]*meshmoduleassigner.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { - t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err) + if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { + t.Errorf("Placed with no assignment: %+v, %v", got.Nodes[1], err) } - if _, err := Placed(want, map[string]*meshmoduleassigner.Placement{"stranger": {Node: "stranger"}}); err == nil { + if _, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"stranger": {Node: "stranger"}}); err == nil { t.Error("Placed a node the input holds no node for") } - if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*meshmoduleassigner.Placement{"laptop": {Node: "anchor"}}}); err == nil { - t.Error("a given placement naming another node was taken") + if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": {Node: "anchor"}}}); err == nil { + t.Error("a given assignment naming another node was taken") } // ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the - // placement the bus holds, and read back it is the store's input again. - for node, placed := range mk().placements { + // assignment the bus holds, and read back it is the store's input again. + for node, placed := range mk().assignments { got, err := Export(want, node) if err != nil { t.Fatal(err) } - if gj, _ := meshmoduleassignerclient.EncodePlacement(got); !meshmoduleassignerclient.SamePlacement(got, placed) { - pj, _ := meshmoduleassignerclient.EncodePlacement(placed) + if gj, _ := meshmoduleassignerclient.EncodeAssignments(got); !meshmoduleassignerclient.SameAssignments(got, placed) { + pj, _ := meshmoduleassignerclient.EncodeAssignments(placed) t.Errorf("%s exported as %s, want %s", node, gj, pj) } } @@ -622,10 +622,10 @@ func TestInput(t *testing.T) { clientArtifacts := []builds.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}} movedMesh := func(built bool) *bus { b := one() - p := b.placements["n1"] + p := b.assignments["n1"] p.Modules = []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}} p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator" - b.placements["n1"] = p + b.assignments["n1"] = p rec := b.records["n1"] rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"} b.records["n1"] = rec @@ -771,9 +771,9 @@ func TestInput(t *testing.T) { `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`) withMailer := func(set bool) *bus { b := mk() - p := b.placements["anchor"] + p := b.assignments["anchor"] p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "mailer"}) - b.placements["anchor"] = p + b.assignments["anchor"] = p b.modules["mailer"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(mailer)} b.manifests[meshcatalogclient.ManifestDigest(mailer)] = mailer if set { @@ -798,7 +798,7 @@ func TestInput(t *testing.T) { says string }{ "no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"}, - "no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"}, + "no assignment": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no assignment"}, "a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"}, "a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"}, } { @@ -840,11 +840,11 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { } out[state][key] = raw } - placements, _ := b.Placements(ctx) - for _, p := range placements { - values, err := meshmoduleassignerclient.PlacementValues(p) + assignments, _ := b.Assignments(ctx) + for _, p := range assignments { + values, err := meshmoduleassignerclient.AssignmentsValues(p) for k, raw := range values { - put(PlacementRead, k, raw, err) + put(AssignmentsRead, k, raw, err) } } records, _ := b.Records(ctx) diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go index 70a7a56..b052bad 100644 --- a/internal/compose/frombus/frombustest/frombustest.go +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -12,8 +12,8 @@ import ( "embed" "encoding/json" "errors" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" @@ -27,7 +27,7 @@ var testdata embed.FS // Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set. type Bus struct { - Placement map[string]meshmoduleassigner.Placement + ByNode map[string]meshmoduleassigner.Assignments Record map[string]nodeengine.NodeRecord Module map[string]meshcatalog.ModulesByModuleValue Manifests map[string][]byte @@ -41,8 +41,8 @@ type Bus struct { var _ frombus.Reader = (*Bus)(nil) -func (b *Bus) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { - return b.Placement, nil +func (b *Bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) { + return b.ByNode, nil } func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { return b.Record, nil @@ -156,7 +156,7 @@ func Provisioned() (*Bus, error) { digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } fps := FPs() b := &Bus{ - Placement: map[string]meshmoduleassigner.Placement{Node: {Node: Node, + ByNode: map[string]meshmoduleassigner.Assignments{Node: {Node: Node, Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go index 3593057..3c2b190 100644 --- a/internal/compose/frombus/frombustest/frombustest_test.go +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -3,10 +3,10 @@ package frombustest import ( "context" "encoding/json" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" @@ -70,9 +70,9 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { } if row.bad != "" { if !row.elsewhere { - p := b.Placement[Node] + p := b.ByNode[Node] p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "backup"}) - b.Placement[Node] = p + b.ByNode[Node] = p } digest := meshcatalogclient.ManifestDigest([]byte(row.bad)) b.Manifests[digest] = []byte(row.bad) @@ -166,19 +166,19 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { byKey := map[string]int{} ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { byKey[state+" watched"]++ - return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true}) + return handle(stdio.StateChange{Key: Node + ".node", Op: stdio.OpPut, Value: []byte(`{}`), Current: true}) }, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil }) w := fromruntime.Watched{WatchedStates: ws} for range 2 { _, _, _ = w.Get(frombus.BuildsRead, "restic."+fp) _, _, _ = w.Get(frombus.ManifestsRead, "sha256-d") - if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found { - t.Error("the watched reader does not answer the placement from its copy") + if _, found, _ := w.Get(frombus.AssignmentsRead, Node+".node"); !found { + t.Error("the watched reader does not answer the assignment from its copy") } } - if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 || - byKey[frombus.PlacementRead+" watched"] != 1 { - t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey) + if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.AssignmentsRead] != 0 || + byKey[frombus.AssignmentsRead+" watched"] != 1 { + t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the assignment watched once", byKey) } fetched := 0 keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil }) @@ -279,10 +279,10 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { // held: its build not done while it runs bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &builds.BuilderBuild{State: builds.BuildBuilding} } else { - // taken out of the node's placement while what runs still names it: the account goes with it, never kept stale - p := bb.Placement[Node] + // taken out of the node's assignment while what runs still names it: the account goes with it, never kept stale + p := bb.ByNode[Node] p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m meshmoduleassigner.PlacedModule) bool { return m.Module == "mesh-provisioner-client" }) - bb.Placement[Node] = p + bb.ByNode[Node] = p want = "" } after, _, held, err := frombus.Compose(context.Background(), bb, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps) @@ -314,9 +314,14 @@ func statesOf(t *testing.T, b *Bus) frombus.States { } s[state][key] = raw } - for node, p := range b.Placement { - raw, err := meshmoduleassignerclient.EncodePlacement(p) - put(frombus.PlacementRead, "placement."+node, raw, err) + for _, p := range b.ByNode { + vals, err := meshmoduleassignerclient.AssignmentsValues(p) + if err != nil { + put(frombus.AssignmentsRead, p.Node+".node", nil, err) + } + for key, raw := range vals { + put(frombus.AssignmentsRead, key, raw, nil) + } } for node, rec := range b.Record { raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec}) diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index 4936bb1..e4d5673 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" @@ -20,7 +20,7 @@ import ( // aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { t.Helper() - p := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} + p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}} b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: map[string]meshcatalog.ModulesByModuleValue{}, manifests: map[string][]byte{}, builds: map[string]*builds.BuilderBuild{}} fps := map[string]string{} @@ -31,7 +31,7 @@ func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { b.manifests[meshcatalogclient.ManifestDigest([]byte(raw))] = []byte(raw) fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64) } - b.placements = map[string]meshmoduleassigner.Placement{"n1": p} + b.assignments = map[string]meshmoduleassigner.Assignments{"n1": p} return b, fps } diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go index 0eb785d..7e9fbd6 100644 --- a/internal/compose/frombus/states.go +++ b/internal/compose/frombus/states.go @@ -2,7 +2,7 @@ package frombus // **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in // mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's -// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each +// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: assignment, each // node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved // values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's // runtime states and an HTTP fetch are compose/fromruntime's. @@ -15,10 +15,10 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" @@ -31,16 +31,16 @@ import ( const ( // The holders' names are kept here, in the engine's own code, as the names of the states a manifest's reads use // (ADR 0634 §1, finding F4); a seat's contract names a state locally, not the module that holds it. - PlacementRead = buckets.AssignerHolder + ".placement" - ModulesRead = buckets.CatalogHolder + ".modules" - ManifestsRead = buckets.CatalogHolder + ".manifests" - BuildsRead = builds.BuilderModule + ".builds" - HaveRead = "node-engine.have" - ResolvedRead = "mesh-config.resolved" + AssignmentsRead = buckets.AssignerHolder + ".assignment" + ModulesRead = buckets.CatalogHolder + ".modules" + ManifestsRead = buckets.CatalogHolder + ".manifests" + BuildsRead = builds.BuilderModule + ".builds" + HaveRead = "node-engine.have" + ResolvedRead = "mesh-config.resolved" ) // StatesReads are those reads, in a manifest's order. -var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead} +var StatesReads = []string{AssignmentsRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead} // States is a module's read states as its runtime gives them: a key's value, false when it has none. type States interface { @@ -85,9 +85,9 @@ func (r statesReader) each(read string, visit func(key string, value []byte)) er return nil } -func (r statesReader) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { +func (r statesReader) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) { keys := meshmoduleassignerclient.NewKeys() - err := r.each(PlacementRead, func(k string, v []byte) { + err := r.each(AssignmentsRead, func(k string, v []byte) { _, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before }) return keys.All(), err diff --git a/internal/compose/frombus/twice.go b/internal/compose/frombus/twice.go index 4411c51..8b0ebf2 100644 --- a/internal/compose/frombus/twice.go +++ b/internal/compose/frombus/twice.go @@ -2,7 +2,7 @@ package frombus // **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2): // the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each -// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests, +// placed module. Both are composed from one read of the bus (assignment, records, the indexer's modules and manifests, // values: base); only the builds the node's modules are composed from differ, each build read once for both, and a // build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase. diff --git a/internal/compose/fromruntime/fromruntime.go b/internal/compose/fromruntime/fromruntime.go index b9fab99..f6e72ad 100644 --- a/internal/compose/fromruntime/fromruntime.go +++ b/internal/compose/fromruntime/fromruntime.go @@ -34,7 +34,7 @@ func (States) Keys(state string) ([]string, error) { return stdio.State(state).K func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) } // Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole -// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest. +// (assignment, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest. type Watched struct{ *stdio.WatchedStates } // Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy. diff --git a/internal/compose/input.go b/internal/compose/input.go index c33657e..e1eaa9b 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -260,7 +260,7 @@ type ComposedHow struct { HeldBefore map[string]string } -// Place is where a node is, as its placement says (inv.Overlay). +// Place is where a node is, as its assignment says (inv.Overlay). type Place struct { Key string Endpoint string diff --git a/internal/compose/network.go b/internal/compose/network.go index 1ba2dfd..09ab10d 100644 --- a/internal/compose/network.go +++ b/internal/compose/network.go @@ -123,10 +123,10 @@ func (c *composer) network(on map[string]bool, refused map[string]string) (*over Site: p.Site, Hub: p.Hub, Address: p.Address, } if t, takes := tunnels[node.Name]; takes && t.NodeAdopted { - // Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is + // Only an adopted node is told to take the found unit over. A hub whose assignment disagrees with the tunnel is // refused, not composed. if wrong := disagrees(p, t.Tunnel); wrong != "" { - return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+ + return nil, fmt.Errorf("%s takes over the tunnel on %s and its assignment disagrees with it: %s. "+ "Re-place it — `overlay place %s --hub --endpoint :%d …` — and push again; "+ "nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port) } @@ -217,7 +217,7 @@ func (c *composer) generators() (map[string]catalogue.Generator, error) { return map[string]catalogue.Generator{overlay.Name: net}, nil } -// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its +// disagrees says how a node's assignment differs from the tunnel it takes over — its address not the tunnel's, its // endpoint not on the tunnel's port — or nothing when both agree. func disagrees(p Place, t Tunnel) string { var wrong []string diff --git a/internal/compose/network_test.go b/internal/compose/network_test.go index 3b1ca59..2cf4657 100644 --- a/internal/compose/network_test.go +++ b/internal/compose/network_test.go @@ -80,7 +80,7 @@ func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) { } } -// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go +// A takeover is composed only for a hub whose assignment agrees with the tunnel (mesh-controller network_test.go // TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop // the found interface and raise the mesh's where no peer is listening. func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) { diff --git a/internal/compose/render.go b/internal/compose/render.go index 3354fad..125569d 100644 --- a/internal/compose/render.go +++ b/internal/compose/render.go @@ -78,7 +78,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { } r := placed.Rendering out.ForeseenPorts = placed.ForeseenPorts - // A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774). + // A plan carries its contributions' machine ports, refused where the assignment assigned none (issue 774). r.Planned = mode == Planning r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound out.Withheld, out.Unbound = withheld, unbound diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index d0b2afe..4bdd41c 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -328,7 +328,7 @@ func (c *composer) namesInTheMesh() map[string]string { } // StoreSeat is the seat whose holder is the artifact store (novox/hq ADR 0632 §1): the store's address is that holder's -// listen open to the mesh, on the machine placement puts it on. The provision artifact-store it was found by before is +// listen open to the mesh, on the machine assignment puts it on. The provision artifact-store it was found by before is // retired; no module serves it. const StoreSeat = "container-image-registry" diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go index 2eaae4c..4f4e73a 100644 --- a/internal/compose/resolution/declaration.go +++ b/internal/compose/resolution/declaration.go @@ -1520,7 +1520,7 @@ func resolutionContributedOf(r mcat.Resolution, m mcat.Manifest, to string, with } // atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number -// `${port:}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's +// `${port:}` gives m itself and its declaration publishes (machinePort, port_into.go), the assigner's // assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed // mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address. // diff --git a/internal/compose/resolution/enrolment_seat.go b/internal/compose/resolution/enrolment_seat.go index 1d30161..68ffb9f 100644 --- a/internal/compose/resolution/enrolment_seat.go +++ b/internal/compose/resolution/enrolment_seat.go @@ -15,22 +15,22 @@ func enrolmentVerbs() []mcat.Verb { } return []mcat.Verb{ {Name: mcat.EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " + - "refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " + + "refused at once, naming every finding, for a name that is not a node name or that a node record, an assignments " + "key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " + "reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " + - "warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " + + "warrant, adds the node to the assignments with its tunnel key, asks the vault for the token and says enrolment-issued. " + "The token itself is read only through tokens.", Input: schema(map[string]string{ "node": "the new machine's name in the mesh", "why": "why it joins, shown to the operator with the ask", "expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", // The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node - // enters placement with it and the hub peers it before redeem. + // enters the assignments with it and the hub peers it before redeem. "overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", }, []string{"node", "why", "overlay_key"})}, {Name: mcat.EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol. " + "alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " + - "vault for the node's account and certificate, writes the node into placement, and answers the account sealed " + + "vault for the node's account and certificate, writes the node into the assignments, and answers the account sealed " + "to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " + "it finishes what is not done and answers the same; with other keys it is refused.", Input: map[string]any{"type": "object", "additionalProperties": false, diff --git a/internal/compose/resolution/keyed_test.go b/internal/compose/resolution/keyed_test.go index 2ca665c..7846f10 100644 --- a/internal/compose/resolution/keyed_test.go +++ b/internal/compose/resolution/keyed_test.go @@ -54,7 +54,7 @@ func TestAModuleNotHandedHasAPlanForWhatAHelperAnswers(t *testing.T) { t.Fatalf("the plan gives %+v (%v)", given, err) } // Its port is where the machine publishes it, the number ${port:9000} gives the module and its declaration publishes, - // never the software's own where the placement moved it (2026-10-31: route-proxy dialled gitea's 3000, published at + // never the software's own where the assigner moved it (2026-10-31: route-proxy dialled gitea's 3000, published at // 20000): its provider dials what the plan says. Where a plan carries it, a port the module does not listen on is // refused by module, node and port. for _, p := range []struct { @@ -65,10 +65,10 @@ func TestAModuleNotHandedHasAPlanForWhatAHelperAnswers(t *testing.T) { port float64 refused string }{ - {"assigned by the placement (gitea 3000 at 20000)", map[string]map[int]int{"notify": {9000: 20009}}, nil, true, 20009, ""}, - {"given by the node over the placement", map[string]map[int]int{"notify": {9000: 20009}}, + {"assigned by the assigner (gitea 3000 at 20000)", map[string]map[int]int{"notify": {9000: 20009}}, nil, true, 20009, ""}, + {"given by the node over the assigner", map[string]map[int]int{"notify": {9000: 20009}}, map[string]map[int]int{"notify": {9000: 9900}}, true, 9900, ""}, - {"a bare published port the placement holds none for keeps its number (influxdb 8086)", + {"a bare published port the assigner holds none for keeps its number (influxdb 8086)", map[string]map[int]int{"notify": {9000: 9000}}, nil, true, 9000, ""}, {"a mapping the module fixed (as mailu 7080:80)", map[string]map[int]int{"notify": {9000: 7080}}, nil, true, 7080, ""}, {"a port the module does not listen on", nil, nil, true, 0, diff --git a/internal/compose/resolution/placement_seat.go b/internal/compose/resolution/placement_seat.go deleted file mode 100644 index e2915ec..0000000 --- a/internal/compose/resolution/placement_seat.go +++ /dev/null @@ -1,59 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10). -// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same -// delivery as its holder. -func placementVerbs() []mcat.Verb { - changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " + - "place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " + - "unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " + - "(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " + - "layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-node-enroller's alone, the " + - "proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " + - "remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-node-enroller's alone, " + - "on the same warrant)", - "items": map[string]any{"type": "object", "required": []string{"op", "node"}}} - return []mcat.Verb{ - {Name: mcat.PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " + - "module is placed on, or every key, each with its revision, and how full the state is against its cap. With " + - "history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " + - "proposal's changes from its placement-changed event.", - Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on", - "history": "\"true\": the changes in order, from the stream and the state's revisions", - // ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders. - "seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"}, - nil, "history", "seats")}, - {Name: mcat.PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " + - "the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " + - "answered, its verdict and its ask. Narrowed to one by id.", - Input: schema(map[string]string{"id": "one proposal's id"}, nil)}, - {Name: mcat.PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " + - "the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " + - "on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " + - "hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " + - "it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " + - "caller, as the runtime proves it. An add-node from mesh-node-enroller, whose warrant names its node and address, " + - "is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", - Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"}, - "properties": map[string]any{ - "why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"}, - "changes": changes, - // ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose - // Approve is the proposal's; the holder writes it with no ask of its own. - "warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " + - "warrant {node, address, ask, warrant}, naming the change's node and address", - "required": []string{"node", "address", "ask", "warrant"}}, - }}}, - {Name: mcat.PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " + - "candidates are deleted and placement-withdrawn carries it whole. With why.", - Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})}, - {Name: mcat.PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " + - "stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " + - "Nothing more: the recovery of a proposal whose judgement or ask was lost.", - Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})}, - } -} diff --git a/internal/compose/resolution/proofs_test.go b/internal/compose/resolution/proofs_test.go index 76d3686..cd174e4 100644 --- a/internal/compose/resolution/proofs_test.go +++ b/internal/compose/resolution/proofs_test.go @@ -408,11 +408,11 @@ func TestRouteProxysProofsOfAComposition(t *testing.T) { placed map[int]int at int }{ - {"beside the proxy, moved by the placement (gitea)", "anchor", "gitea", 3000, []any{"3000"}, map[int]int{3000: 20000}, 20000}, - {"on another machine, moved by the placement (grafana)", "home", "grafana", 3000, []any{"3000"}, map[int]int{3000: 20012}, 20012}, - {"a bare port the placement holds none for (influxdb)", "home", "influxdb", 8086, []any{"8086"}, nil, 8086}, + {"beside the proxy, moved by the assigner (gitea)", "anchor", "gitea", 3000, []any{"3000"}, map[int]int{3000: 20000}, 20000}, + {"on another machine, moved by the assigner (grafana)", "home", "grafana", 3000, []any{"3000"}, map[int]int{3000: 20012}, 20012}, + {"a bare port the assigner holds none for (influxdb)", "home", "influxdb", 8086, []any{"8086"}, nil, 8086}, {"a fixed mapping (mailu 7080:80)", "anchor", "mailu", 7080, []any{"25", "7080:80"}, nil, 7080}, - {"a fixed mapping the placement names another port of (photos 9102:9000)", "anchor", "photos", 9102, + {"a fixed mapping the assigner names another port of (photos 9102:9000)", "anchor", "photos", 9102, []any{"9102:9000"}, map[int]int{9000: 20011}, 9102}, {"the host's network (home-assistant)", "anchor", "home-assistant", 8123, nil, nil, 8123}, } { diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 3e5f3f2..efd4688 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -10,8 +10,8 @@ package composer import ( "encoding/json" "fmt" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "slices" @@ -138,19 +138,19 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { if err != nil { return CaseVerdict{}, err } - placements := map[string]*meshmoduleassigner.Placement{} - for node, raw := range c.Placements { + assignments := map[string]*meshmoduleassigner.Assignments{} + for node, raw := range c.Assignments { if string(raw) == "null" { - placements[node] = nil + assignments[node] = nil continue } - p, perr := meshmoduleassignerclient.DecodePlacement(raw) + p, perr := meshmoduleassignerclient.DecodeAssignments(raw) if perr != nil { - return CaseVerdict{}, fmt.Errorf("composer: the case %q: the placement of %s is not one: %w", c.Name, node, perr) + return CaseVerdict{}, fmt.Errorf("composer: the case %q: the assignment of %s is not one: %w", c.Name, node, perr) } - placements[node] = &p + assignments[node] = &p } - in, err := frombus.Placed(base, placements) + in, err := frombus.Placed(base, assignments) if err != nil { return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) } diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index c9ff7e1..bd2e9d6 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -3,7 +3,7 @@ package composer import ( "context" "encoding/json" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" @@ -55,17 +55,17 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil { t.Error("an unknown depth runs") } - // A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not. + // A case's own assignment (issue 1126) composes over a node the input holds, and is refused for one it does not. placed := withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthResolve, Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}, {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}, - Placements: bucketstest.Raw(map[string]meshmoduleassigner.Placement{"a": {V: 1, Node: "a", Modules: []meshmoduleassigner.PlacedModule{{Module: "ghost"}}}})}}}, + Assignments: bucketstest.Raw(map[string]meshmoduleassigner.Assignments{"a": {V: 1, Node: "a", Modules: []meshmoduleassigner.PlacedModule{{Module: "ghost"}}}})}}}, compose.Input{Nodes: []compose.Node{{Name: "a"}}}) if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes || !strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") { - t.Errorf("a case composes its own placement: %+v, %v", v, err) + t.Errorf("a case composes its own assignment: %+v, %v", v, err) } - placed.Cases[1].Placements = bucketstest.Raw(map[string]meshmoduleassigner.Placement{"z": {V: 1, Node: "z"}}) + placed.Cases[1].Assignments = bucketstest.Raw(map[string]meshmoduleassigner.Assignments{"z": {V: 1, Node: "z"}}) if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") { t.Errorf("a case placing a node the input lacks: %v", err) } diff --git a/internal/compositions/candidate.go b/internal/compositions/candidate.go index 0bed69d..b80a54d 100644 --- a/internal/compositions/candidate.go +++ b/internal/compositions/candidate.go @@ -15,8 +15,8 @@ const ( MaxAnswerBytes = 160 << 10 ) -// ReturnsToNothing is what a node answers a proposal that takes its placement key away. -const ReturnsToNothing = "the proposal takes this node's placement away: it composes nothing then" +// ReturnsToNothing is what a node answers a proposal that takes its assignment key away. +const ReturnsToNothing = "the proposal takes this node's assignment away: it composes nothing then" // NodeWord is the last part of a node's own key, and the name of its own composition in an answer. const NodeWord = "node" diff --git a/internal/compositions/composed_compare.go b/internal/compositions/composed_compare.go index 446021b..ec3606e 100644 --- a/internal/compositions/composed_compare.go +++ b/internal/compositions/composed_compare.go @@ -24,7 +24,7 @@ type Comparison struct { } // CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones -// it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its +// it runs, as its agent would (ADR 0559 §4: a node's answer to a assignment candidate). A module is the same when its // composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing // of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and // not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go index a3b0dce..60bc2b1 100644 --- a/internal/inventory/inventory_test.go +++ b/internal/inventory/inventory_test.go @@ -54,7 +54,7 @@ func TestMemoryIsReadOrReportedMissing(t *testing.T) { t.Errorf("memoryFrom returned %d, %v", kb, ok) } // A meminfo without MemTotal must not read as zero memory — a machine reporting no memory - // would be excluded from placement for a parsing failure. + // would be excluded from assignment for a parsing failure. if _, ok := memoryFrom("MemFree: 1 kB\n"); ok { t.Error("a meminfo with no MemTotal was read as a successful measurement") } diff --git a/internal/judge/aliases.go b/internal/judge/aliases.go index 2ad8d2e..a8ed3f9 100644 --- a/internal/judge/aliases.go +++ b/internal/judge/aliases.go @@ -1,5 +1,5 @@ // Package judge is the node-engine's judgement of a move (novox/hq ADR 0377 §3, ADR 0547 §3): what moving a module from -// the composition it runs to a new one does, and how a placement candidate's answer is fitted to the node's key. It +// the composition it runs to a new one does, and how a assignment candidate's answer is fitted to the node's key. It // moved here from mesh-sdk's delivery package (novox/hq ADR 0593 §1, §7), since only the node-engine judges a move; // the shapes it judges (a composition, a judgement's words, a candidate's answer) stay in mesh-sdk. package judge diff --git a/internal/judge/answer_test.go b/internal/judge/answer_test.go index 70e0cc7..eb4c9ee 100644 --- a/internal/judge/answer_test.go +++ b/internal/judge/answer_test.go @@ -8,7 +8,7 @@ import ( ) // An answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay, and the answer -// given is not changed (moved from mesh-sdk's placement test with FitAnswer, novox/hq ADR 0593 §7). +// given is not changed (moved from mesh-sdk's assignment test with FitAnswer, novox/hq ADR 0593 §7). func TestFitAnswer(t *testing.T) { long := strings.Repeat("x", compositions.MaxBody) big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictDiffers, Differs: []ModuleDiffers{{ diff --git a/internal/link/enrol_seat.go b/internal/link/enrol_seat.go index f75cbdd..3d11c38 100644 --- a/internal/link/enrol_seat.go +++ b/internal/link/enrol_seat.go @@ -159,7 +159,7 @@ type Joining struct { // Run joins: the answer is verified before anything is kept (an answer not signed by the key the token carries keeps nothing), // kept, and then the machine dials as the login its key of node-logins names and confirms. Nothing is applied here: the engine -// applies once its placement names a module. +// applies once its assignment names a module. func (j Joining) Run(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) { answer, err := j.Redeem(ctx) if err != nil { diff --git a/internal/link/have.go b/internal/link/have.go index 0047e6f..063ac25 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -52,7 +52,7 @@ type haveState struct { // nodeSealed is, on a node composing its own, each value its own composition carries sealed to its key (ADR 0547 §3, // issue 1126): kept in its key so a restart reuses them and applies nothing again. nodeSealed compositions.Seals - // nodeCandidates is the node's answer to each placement candidate proposal (novox/hq ADR 0559 §4), written into its key. + // nodeCandidates is the node's answer to each assignment candidate proposal (novox/hq ADR 0559 §4), written into its key. nodeCandidates map[string]nodeengine.CandidateAnswer // nodeComposes and nodeComposesWhy are whose compositions the node applies and why (HaveNode.Composes), written into // its key. @@ -748,7 +748,7 @@ func (q *Session) NoteNode(ctx context.Context, composed string, sealed composit q.writeHaves(ctx, bus) } -// NoteCandidates takes the node's answers to the placement candidates, by proposal (its shadow composer's, novox/hq ADR +// NoteCandidates takes the node's answers to the assignment candidates, by proposal (its shadow composer's, novox/hq ADR // 0559 §4), and writes the node's key where it differs; a proposal left out is no longer answered. func (q *Session) NoteCandidates(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) { q.init() diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 79a8b94..71c841b 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -617,7 +617,7 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth( if n, ok := q.ReadNodeHave(context.Background()); !ok || n.Composed != dig || len(n.SealedAt) != 1 { t.Fatalf("the node key read back: %+v %v", n, ok) } - // The node's answers to placement candidates ride in the same key (ADR 0559 §4), and go when no group is answered. + // The node's answers to assignment candidates ride in the same key (ADR 0559 §4), and go when no group is answered. answers := map[string]nodeengine.CandidateAnswer{"p-1": {Proposal: "p-1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"web"}}} q.NoteCandidates(context.Background(), answers) if n, err := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Candidates["p-1"].Proposal != "p-1" || n.Composed != dig { diff --git a/internal/link/history_integration_test.go b/internal/link/history_integration_test.go index 38e1617..61dbc42 100644 --- a/internal/link/history_integration_test.go +++ b/internal/link/history_integration_test.go @@ -71,7 +71,7 @@ func TestNatsAKeysRevisionsAreReadBack(t *testing.T) { } // A bucket that is not there, and one the account may not read, are errors at once, never a wait: the node agent - // follows its placement key by one watch at its start (endWhenPlaced) and, refused, stays on the controller's compositions and says why. + // follows its assignment key by one watch at its start (endWhenPlaced) and, refused, stays on the controller's compositions and says why. if _, err := bus.KVWatch(ctx, "never-it", ">"); err == nil { t.Error("a watch of a bucket that is not there gave no error") } @@ -112,13 +112,13 @@ func TestNatsAKeysRevisionsAreReadBack(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := later.PutString("placement.n1", "v"); err != nil { + if _, err := later.PutString("n1.node", "v"); err != nil { t.Fatal(err) } for seen := false; !seen; { select { case u := <-ch: - seen = u.Key == "placement.n1" && string(u.Value) == "v" + seen = u.Key == "n1.node" && string(u.Value) == "v" case <-actx.Done(): t.Fatal("the bucket made after the watch was never watched") } diff --git a/internal/link/watch.go b/internal/link/watch.go index 9f03840..948a675 100644 --- a/internal/link/watch.go +++ b/internal/link/watch.go @@ -25,7 +25,7 @@ type KVUpdate struct { type KVBus interface { // KVWatch yields the keys of the bucket matching the pattern now, a Caught update, then every change, until ctx ends // or the link does (the channel is then closed). A bucket the node's account may not read is an error. The pattern - // may be several, separated by spaces (WatchPatterns), watched as one: a node's placement in either form of its + // may be several, separated by spaces (WatchPatterns), watched as one: a node's assignment in either form of its // keys (research 072 row 37). KVWatch(ctx context.Context, bucket, pattern string) (<-chan KVUpdate, error) } @@ -269,7 +269,7 @@ func CreatedAdvisory(bucket string) string { return "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_" + bucket } -// KVWatchAwait is KVWatch for a bucket that may not be on the bus yet (the placement seat's states, made when their +// KVWatchAwait is KVWatch for a bucket that may not be on the bus yet (the assignment seat's states, made when their // holder starts): the channel is returned at once, and while the bucket is missing it yields nothing and waiting is told // why; when the bus says the bucket's stream was created the watch is made and its updates follow. No timer: the // advisory is heard before the first try, so a creation between the try and the wait is not missed. Any other error of diff --git a/internal/nodegrant/testdata/node.json b/internal/nodegrant/testdata/node.json index bfb8c60..788e0cb 100644 --- a/internal/nodegrant/testdata/node.json +++ b/internal/nodegrant/testdata/node.json @@ -4,7 +4,7 @@ "$JS.API.CONSUMER.CREATE.KV_composed-NODE.>", "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.NODE.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", - "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", + "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.NODE.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.>", @@ -22,7 +22,7 @@ "$JS.API.CONSUMER.DELETE.KV_composed-NODE.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", - "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", + "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.>", @@ -40,7 +40,7 @@ "$JS.API.DIRECT.GET.KV_composed-NODE.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.NODE.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", - "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", + "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.NODE.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.>", @@ -58,7 +58,7 @@ "$JS.API.STREAM.INFO.KV_composed-NODE", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", - "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", + "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates", @@ -75,7 +75,7 @@ "$JS.FC.KV_composed-NODE.>", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-delivery-assigner_candidates.>", - "$JS.FC.KV_mesh-delivery-assigner_placement.>", + "$JS.FC.KV_mesh-delivery-assigner_assignments.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_mesh-delivery-builder_builds.>", "$JS.FC.KV_mesh-delivery-indexer_candidates.>", @@ -112,7 +112,7 @@ "PublishDeny": null, "Subscribe": [ "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates", - "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement", + "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments", "$SRV.INFO", "$SRV.INFO.node-engine", "$SRV.INFO.node-engine.>", diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index 023db96..3791f1e 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -6,9 +6,9 @@ import ( "encoding/json" "errors" "fmt" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets" engcomp "github.com/novox/mesh-host/internal/compositions" "maps" @@ -28,7 +28,7 @@ import ( // **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 §7): the node seat's verb compose-candidate. // Given the check's composition key (`.`), it reads the group's candidate from the indexer // (buckets.Candidates, keyed by the group alone, buckets.CandidateKey) at those heads, and composes itself -// twice from what its shadow holds (placement, records, the indexer's modules, its config values), with its own sealed +// twice from what its shadow holds (assignment, records, the indexer's modules, its config values), with its own sealed // values: once as it is, and once with the candidate's modules laid over the indexer's (each at the candidate's // fingerprint, its manifest by digest and the indexer's verdict on it, ADR 0578 §3; a module the candidate removes left // out). It answers per module placed here composed (its digest, and whether the candidate changes it), refused by the @@ -315,7 +315,7 @@ func (c *Composer) scratch() *Composer { out := New(c.env) c.mu.Lock() defer c.mu.Unlock() - out.placements = maps.Clone(c.placements) + out.assignments = maps.Clone(c.assignments) out.records = maps.Clone(c.records) out.inOwn = maps.Clone(c.inOwn) out.modules = maps.Clone(c.modules) @@ -326,7 +326,7 @@ func (c *Composer) scratch() *Composer { out.sealedOwn = maps.Clone(c.sealedOwn) out.ownHanded = maps.Clone(c.ownHanded) out.certified = c.certified - out.placementKeys = c.placementKeys + out.assignmentKeys = c.assignmentKeys out.origin = c return out } @@ -433,14 +433,14 @@ func (c *Composer) layCandidate(ctx context.Context, cand meshcatalog.Candidates func (c *Composer) composeShadow(ctx context.Context, running []engcomp.Composed, laid map[string]bool, input func(frombus.Scope, map[string]string)) (map[string]engcomp.Composed, map[string]string, error) { node := c.env.Node c.mu.Lock() - placement, placed := c.placements[node] + assignment, placed := c.assignments[node] modules := maps.Clone(c.modules) - inOwn := c.inOwnOf(placement) + inOwn := c.inOwnOf(assignment) c.mu.Unlock() if !placed { - return nil, nil, errors.New("this node's key in the seat's placement state is gone: it composes nothing of its own") + return nil, nil, errors.New("this node's key in the seat's assignment state is gone: it composes nothing of its own") } - fps := shadowBuilds(running, placement, modules, inOwn) + fps := shadowBuilds(running, assignment, modules, inOwn) // A module the laid candidate changes is composed at the candidate's build, though the node runs another. for module := range fps { if mod, ok := modules[module]; ok && laid[module] { diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index e659478..b1513be 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -3,8 +3,7 @@ package selfcompose import ( "context" "encoding/json" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -25,7 +24,7 @@ import ( // novox/hq ADR 0593 §7: the verb compose-candidate composes a check's candidate on this node in shadow. A candidate that // changes a placed module composes it at the candidate's build, changed; one whose manifest the indexer refused is // answered refused, naming the module and the judge's words, and the verdict is refused; a candidate the indexer does not -// hold is refused in words. Nothing is applied, nothing is written, no placement answer is handed, and the answer comes +// hold is refused in words. Nothing is applied, nothing is written, no assignment answer is handed, and the answer comes // within the bound; a shadow not ready and an ended bound answer unready at once, which the checker takes as no answer. func TestTheNodeComposesACandidateOnAsk(t *testing.T) { b := newFakeBus() @@ -87,7 +86,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { if a := s.ComposeCandidate(ctx, nodeengine.ComposeCandidateInput{Candidate: accepted}); a.Verdict != nec.Unready || a.Refused == "" { t.Errorf("a shadow not ready answered %+v", a) } - place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) have, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) @@ -96,10 +95,10 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { for watching() < 6 && time.Now().Before(deadline) { time.Sleep(5 * time.Millisecond) } - b.put(t, buckets.Placement, "placement.n1", place) + b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Modules, "lemurs", mod) - for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket} { + for _, bucket := range []string{buckets.ProposalCandidates, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket} { b.send(bucket, agent.Update{Caught: true}) } for !s.ready.Load() && time.Now().Before(deadline) { @@ -185,7 +184,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { var tick atomic.Int64 env.Now = func() time.Time { return time.Unix(0, 0).Add(time.Duration(tick.Add(1)) * time.Second) } s := NewShadow(env, nil) - s.c.placements["n1"] = meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + s.c.assignments["n1"] = meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} s.c.records["n1"] = nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}} s.c.modules["lemurs"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"} diff --git a/internal/selfcompose/composedstate.go b/internal/selfcompose/composedstate.go index 5c4dab7..6793a85 100644 --- a/internal/selfcompose/composedstate.go +++ b/internal/selfcompose/composedstate.go @@ -54,7 +54,7 @@ type published struct { fps map[string]string // by module, the wanted fingerprint comps map[string]engcomp.Composed held map[string]string - // placed is the modules the node's placement puts here, ran what runs of each module (by its composition's FP). + // placed is the modules the node's assignment puts here, ran what runs of each module (by its composition's FP). placed []string ran map[string]string } diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index 9f57406..a1a45f8 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -3,12 +3,11 @@ package selfcompose import ( "context" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/buckets/bucketstest" @@ -91,20 +90,20 @@ func TestTheNodePublishesItsCompositions(t *testing.T) { if err != nil { t.Fatal(err) } - place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) omod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) - b.put(t, buckets.Placement, "placement.n1", place) + b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Modules, "lemurs", mod) b.put(t, buckets.Modules, "oldie", omod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } for u := next(t, out); !u.Caught; u = next(t, out) { diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 5519e1d..080dca2 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -2,7 +2,7 @@ // modules and its own composition with mesh-sdk's composer, from what is on the bus, and hands the compositions to the // agent as the agent once read them from the controller's bucket `composed-`. // -// **It watches, it never polls** (ADR 0547 §2): its inputs are watches of the node's wants, the indexer's placement and +// **It watches, it never polls** (ADR 0547 §2): its inputs are watches of the node's wants, the indexer's assignment and // modules, every node's own have key (where a node's record rides, ADR 0555 §3) and mesh-config's resolved values of this // node. A change of any of them marks the node to compose again; one loop drains the mark, so a burst of changes composes // the node once (composing one node takes 0.8 to 4.4 s, measured 2026-10-26). It hands the agent only the compositions that @@ -19,14 +19,14 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/buckets" @@ -135,13 +135,13 @@ const VaultSecretChanged = "mesh.mod.mesh-vault.event.secret-changed" type Composer struct { env Env - mu sync.Mutex - wants map[string]meshwants.WantsByWantNodeModuleValue // by module, the node's own under agent.NodeModule - placements map[string]meshmoduleassigner.Placement - // placementKeys is the seat's state placement key by key in either form, each node's placement assembled from it + mu sync.Mutex + wants map[string]meshwants.WantsByWantNodeModuleValue // by module, the node's own under agent.NodeModule + assignments map[string]meshmoduleassigner.Assignments + // assignmentKeys is the seat's state assignment key by key in either form, each node's assignment assembled from it // (meshmoduleassignerclient.Keys; research 072 row 37): a write of the holder's under way is never composed half. - placementKeys *meshmoduleassignerclient.Keys - records map[string]nodeengine.NodeRecord + assignmentKeys *meshmoduleassignerclient.Keys + records map[string]nodeengine.NodeRecord // inOwn is, by module, whether this node's have of it names no fingerprint of the new form (mesh-wireguard, a module // at a build of the old form): the node runs it inside its own composition, and composes it there again (issue 1126). inOwn map[string]bool @@ -307,7 +307,7 @@ func New(env Env) *Composer { if env.Say == nil { env.Say = func(string) {} } - return &Composer{env: env, wants: map[string]meshwants.WantsByWantNodeModuleValue{}, placements: map[string]meshmoduleassigner.Placement{}, placementKeys: meshmoduleassignerclient.NewKeys(), + return &Composer{env: env, wants: map[string]meshwants.WantsByWantNodeModuleValue{}, assignments: map[string]meshmoduleassigner.Assignments{}, assignmentKeys: meshmoduleassignerclient.NewKeys(), records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]meshcatalog.ModulesByModuleValue{}, values: map[string]map[string]any{}, manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]builds.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} @@ -363,7 +363,7 @@ func (c *Composer) inputs() []input { node := c.env.Node return []input{ {buckets.Wants, "want." + node + ".>", (*Composer).takeWant, nil}, - {buckets.Placement, ">", (*Composer).takePlacement, nil}, + {buckets.Assignments, ">", (*Composer).takeAssignments, nil}, // Every node's record, and this node's own haves (what it runs inside its own composition): two watches, each // within the node's grant (buscompose: readGrants of the have bucket at `*.node`, NodeKeysGrants at `.>`). // A watch of every key is refused by the bus (issue 1126, 2026-10-27 09:21: the fold's `>` stopped every agent). @@ -396,7 +396,7 @@ func (c *Composer) takeBuildDone(u agent.Update) bool { w, wanted := c.wants[module] m := c.modules[module] placedHere := false - for _, pm := range c.placements[c.env.Node].Modules { + for _, pm := range c.assignments[c.env.Node].Modules { placedHere = placedHere || pm.Module == module } c.mu.Unlock() @@ -593,7 +593,7 @@ func (c *Composer) caughtSent() bool { return sent } -// takeChanged takes u through in.take and says whether the entry it names changed: a want, a placement, a module or a +// takeChanged takes u through in.take and says whether the entry it names changed: a want, a assignment, a module or a // config value written again with the value it held changes nothing composed, so it makes no pass (issue 774, // 2026-10-31: every put under want..> made a pass, and each pass asked the vault for the node's own secrets). func (c *Composer) takeChanged(in input, u agent.Update) bool { @@ -623,14 +623,14 @@ func (c *Composer) entry(bucket, key string) (v any, has, known bool) { } w, ok := c.wants[module] return w.Fp, ok, true - case buckets.Placement: - // Any key of a node, its node key, a module key or its legacy key, stands for the node's whole placement: a + case buckets.Assignments: + // Any key of a node, its node key, a module key or its legacy key, stands for the node's whole assignment: a // module key written before its node key names it changes nothing composed. node, _, _, err := meshmoduleassignerclient.ParseKey(key) if err != nil { return nil, false, true } - p, ok := c.placements[node] + p, ok := c.assignments[node] return p, ok, true case buckets.Modules: name, err := meshcatalogclient.ParseModulesKey(key) @@ -677,25 +677,25 @@ func (c *Composer) takeWant(u agent.Update) { c.wants[module] = w } -func (c *Composer) takePlacement(u agent.Update) { +func (c *Composer) takeAssignments(u agent.Update) { if u.Caught { return } c.mu.Lock() - if c.placementKeys == nil { - c.placementKeys = meshmoduleassignerclient.NewKeys() + if c.assignmentKeys == nil { + c.assignmentKeys = meshmoduleassignerclient.NewKeys() } - node, err := c.placementKeys.Put(u.Key, u.Value, u.Revision, u.Deleted || len(u.Value) == 0) + node, err := c.assignmentKeys.Put(u.Key, u.Value, u.Revision, u.Deleted || len(u.Value) == 0) if node != "" { - if p, _, ok := c.placementKeys.Of(node); ok { - c.placements[node] = p + if p, _, ok := c.assignmentKeys.Of(node); ok { + c.assignments[node] = p } else { - delete(c.placements, node) + delete(c.assignments, node) } } c.mu.Unlock() if node != "" && err != nil && c.env.Say != nil { - c.env.Say("the seat's placement of " + node + ": " + err.Error()) + c.env.Say("the seat's assignment of " + node + ": " + err.Error()) } } @@ -758,14 +758,14 @@ func (c *Composer) takeModuleHave(u agent.Update) bool { } // inOwnOf is, of the modules p places, those the node runs inside its own composition (c.inOwn), sorted; c.mu held. -// Machines is, from the placements this composer holds, each node by its address on the private network, and the hub's +// Machines is, from the assignments this composer holds, each node by its address on the private network, and the hub's // node: what names the machine a failing network part points at (nodeengine.NetworkPart.Targets, issue 774). Empty -// before any placement is read. +// before any assignment is read. func (c *Composer) Machines() (byAddress map[string]string, hub string) { c.mu.Lock() defer c.mu.Unlock() byAddress = map[string]string{} - for node, p := range c.placements { + for node, p := range c.assignments { if a, _, _ := strings.Cut(p.Decided.Address, "/"); a != "" { byAddress[a] = node } @@ -776,7 +776,7 @@ func (c *Composer) Machines() (byAddress map[string]string, hub string) { return byAddress, hub } -func (c *Composer) inOwnOf(p meshmoduleassigner.Placement) []string { +func (c *Composer) inOwnOf(p meshmoduleassigner.Assignments) []string { var out []string for _, m := range p.Modules { if c.inOwn[m.Module] { @@ -874,14 +874,14 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { _, nodeWanted := c.wants[agent.NodeModule] // What it runs inside its own composition and no want names at a build of the new form stays there, as the // controller's composed writer folds it (issue 1126); compose folds none a want names. - own, placed := c.placements[c.env.Node] + own, placed := c.assignments[c.env.Node] inOwn := c.inOwnOf(own) r := reader{c: c} c.mu.Unlock() if !placed { // The catalogue does not place it: it composes nothing, applies no composition made elsewhere, and this is no // refusal of what it runs (issue 1126). - c.sayOnce("this node's key in the seat's placement state is gone: it composes nothing of its own, and keeps running what it runs") + c.sayOnce("this node's key in the seat's assignment state is gone: it composes nothing of its own, and keeps running what it runs") c.paid() // nothing of its own to compose, so no pass is owed and no timer armed again return nil } @@ -1317,10 +1317,10 @@ func (r reader) VaultAuthority(context.Context) string { var _ frombus.SignersReader = reader{} -// certificateWanted says whether a module the placement p puts on this node asks for a certificate for the machine's +// certificateWanted says whether a module the assignment p puts on this node asks for a certificate for the machine's // internal name (its manifest's `certificate`), read from the indexer's manifests; a manifest that cannot be read is // taken as asking none, and the composition says what it misses. -func (c *Composer) certificateWanted(ctx context.Context, r reader, p meshmoduleassigner.Placement) bool { +func (c *Composer) certificateWanted(ctx context.Context, r reader, p meshmoduleassigner.Assignments) bool { for _, m := range p.Modules { c.mu.Lock() digest := c.modules[m.Module].Manifest @@ -1348,7 +1348,7 @@ func (c *Composer) certificateWanted(ctx context.Context, r reader, p meshmodule // certifyIfWanted asks Certify once per serving key when a module placed here asks for a certificate and this node's // record holds none for its internal name and serving key from an authority it trusts (the one it holds, or the vault's). -func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p meshmoduleassigner.Placement) { +func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p meshmoduleassigner.Assignments) { if c.env.Certify == nil || !c.certificateWanted(ctx, r, p) { return } @@ -1377,11 +1377,11 @@ func (c *Composer) certifyIfWanted(ctx context.Context, r reader, p meshmoduleas c.mu.Unlock() } -func (r reader) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { +func (r reader) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) { r.c.mu.Lock() defer r.c.mu.Unlock() - out := make(map[string]meshmoduleassigner.Placement, len(r.c.placements)) - for k, v := range r.c.placements { + out := make(map[string]meshmoduleassigner.Assignments, len(r.c.assignments)) + for k, v := range r.c.assignments { out[k] = v } return out, nil @@ -1721,7 +1721,7 @@ func Transient(err error) bool { // ComposedToday is the digest of module composed at the build fp with today's settings and secrets, as this node would // compose it now beside its other modules at their wanted builds, the boxes it runs kept as compose keeps them, and whether // it composed (novox/hq ADR 0601 §1): the node agent compares it with what runs, to say whether a move to another build -// changes the build alone. A module held, a node refused, or no placement: false, and the move is asked of a person. +// changes the build alone. A module held, a node refused, or no assignment: false, and the move is asked of a person. func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string, bool) { c.mu.Lock() fps, kept := map[string]string{}, map[string]string{} @@ -1739,7 +1739,7 @@ func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string if mm, ok := c.modules[module]; !ok || mm.Fp2 != fp { kept[module] = fp } - own, placed := c.placements[c.env.Node] + own, placed := c.assignments[c.env.Node] inOwn := c.inOwnOf(own) c.mu.Unlock() if !placed { diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 0a4dcee..fc714ca 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -8,13 +8,13 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/buckets" @@ -197,13 +197,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { okey, _ := meshcatalogclient.ManifestsKey(odigest) b.kv[buckets.Manifests+"/"+okey] = oman omod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64), Manifest: odigest, Repository: "novox/o", Commit: "c"}) - place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) - b.put(t, buckets.Placement, "placement.n1", place) + b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) oldHave, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) b.put(t, buckets.Have, "n1.oldie", oldHave) @@ -211,7 +211,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.put(t, buckets.Modules, "oldie", omod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } got := map[string]bool{} @@ -324,8 +324,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { for k, v := range c.wants { c2.wants[k] = v } - for k, v := range c.placements { - c2.placements[k] = v + for k, v := range c.assignments { + c2.assignments[k] = v } for k, v := range c.records { c2.records[k] = v @@ -441,9 +441,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { c3.modules[m] = meshcatalog.ModulesByModuleValue{Fp2: mfp, Manifest: dig, Repository: "novox/" + m, Commit: "c"} c3.wants[m] = meshwants.WantsByWantNodeModuleValue{Fp: mfp, Self: true} } - p3 := c3.placements["n1"] + p3 := c3.assignments["n1"] p3.Modules = append(append([]meshmoduleassigner.PlacedModule{}, p3.Modules...), meshmoduleassigner.PlacedModule{Module: "proxy"}, meshmoduleassigner.PlacedModule{Module: "de-spiegel"}) - c3.placements["n1"] = p3 + c3.assignments["n1"] = p3 r3 := c3.records["n1"] r3.Capabilities = []string{"systemd", "container-runtime"} c3.records["n1"] = r3 @@ -495,9 +495,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // The indexer's verdict on the captured manifest (ADR 0578): the engine judges nothing, it takes this. Verdict: meshcatalogclient.VerdictRefused, Problems: []string{`bar item "shows" is none of them`}} c4.wants["power"] = meshwants.WantsByWantNodeModuleValue{Fp: powerFP, Self: true} - p4 := c4.placements["n1"] + p4 := c4.assignments["n1"] p4.Modules = append(append([]meshmoduleassigner.PlacedModule{}, p4.Modules...), meshmoduleassigner.PlacedModule{Module: "power"}) - c4.placements["n1"] = p4 + c4.assignments["n1"] = p4 handed4 := map[string]agent.Update{} for _, u := range c4.compose(ctx) { handed4[u.Key] = u @@ -513,10 +513,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // what runs nor the kept declaration: with nothing running (the kept read retired, Running empty) the node's own // still carries its resources, so retiring the read removes nothing of it. c5, told5 := fresh(env.Get) - p5 := c5.placements["n1"] + p5 := c5.assignments["n1"] p5.Modules = append(append([]meshmoduleassigner.PlacedModule{}, p5.Modules...), meshmoduleassigner.PlacedModule{Module: overlay.Name}) p5.Decided.Endpoint = "n1.example:51820" - c5.placements["n1"] = p5 + c5.assignments["n1"] = p5 c5.inOwn[overlay.Name] = true nodeKey5, _ := compositions.NodeKey("n1") var own5 string @@ -597,12 +597,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // secrets. A key written again with what it held, or a want whose words moved and not its build, makes none. worded, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true, Words: "lemurs is held at aaaaaaaaaaaa"}) elsewhere, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("e", 64), Self: true}) - // Research 072 row 37: the placement converted to one key per module and node moves nothing composed; a write in - // keys makes its pass at its node key, the commit, and not at a module key written before it nor at the mirror. - placeBase := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + // Research 072 row 37: the assignments kept as one key per module and node move nothing composed when written again; a + // write in keys makes its pass at its node key, the commit, and not at a module key written before it. + placeBase := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}} placeKeys := meshmoduleassignerclient.NewKeys() - keyRows := func(after meshmoduleassigner.Placement, what string) []struct { + keyRows := func(after meshmoduleassigner.Assignments, what string) []struct { name, bucket, key string value []byte pass bool @@ -622,23 +622,25 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { name, bucket, key string value []byte pass bool - }{what + ": " + w.Key, buckets.Placement, w.Key, w.Value, w.Commit && what != "converted"}) + }{what + ": " + w.Key, buckets.Assignments, w.Key, w.Value, w.Commit}) } return rows } - _, _ = placeKeys.Put("placement.n1", place, 1, false) + for k, v := range place { + _, _ = placeKeys.Put(k, v, 1, false) + } lemursMoved := placeBase lemursMoved.Modules = []meshmoduleassigner.PlacedModule{{Module: "lemurs", Values: map[string]any{"greeting": "hoi"}}, {Module: "oldie"}} - var placementRows []struct { + var assignmentRows []struct { name, bucket, key string value []byte pass bool } for _, step := range []struct { what string - p meshmoduleassigner.Placement - }{{"converted", placeBase}, {"lemurs moved", lemursMoved}, {"lemurs back", placeBase}} { - placementRows = append(placementRows, keyRows(step.p, step.what)...) + p meshmoduleassigner.Assignments + }{{"lemurs moved", lemursMoved}, {"lemurs back", placeBase}} { + assignmentRows = append(assignmentRows, keyRows(step.p, step.what)...) } for _, row := range append([]struct { name, bucket, key string @@ -648,11 +650,11 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { {"lemurs's want again", buckets.Wants, "want.n1.lemurs", want, false}, {"the node's own want again", buckets.Wants, "want.n1.node", nodeWant, false}, {"lemurs's want with other words", buckets.Wants, "want.n1.lemurs", worded, false}, - {"the placement again", buckets.Placement, "placement.n1", place, false}, + {"the assignment again", buckets.Assignments, "n1.node", place["n1.node"], false}, {"lemurs's module again", buckets.Modules, "lemurs", mod, false}, {"lemurs's want at another build", buckets.Wants, "want.n1.lemurs", elsewhere, true}, {"lemurs's want back at its build", buckets.Wants, "want.n1.lemurs", want, true}, - }, placementRows...) { + }, assignmentRows...) { before := passes() b.put(t, row.bucket, row.key, row.value) if !row.pass { @@ -698,18 +700,18 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - tplace, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + tplace := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "tool"}}}) tmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: next2, Manifest: tdigest, Repository: "novox/t", Commit: "c"}) twant, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: next2, Self: true}) building, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: "building"}) bkey, _ := builds.BuilderBuildKey("tool", next2) tb.kv[builds.BuilderBuildsBucket+"/"+bkey] = building - tb.put(t, buckets.Placement, "placement.n1", tplace) + tb.putAssigned(t, tplace) tb.put(t, buckets.Have, "n1.node", have) tb.put(t, buckets.Modules, "tool", tmod) tb.put(t, buckets.Wants, "want.n1.tool", twant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, + for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { tb.send(bucket, agent.Update{Caught: true}) } @@ -777,18 +779,19 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b.kv[buckets.Manifests+"/"+kkey] = kman b.mu.Unlock() kmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}) - both, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + both := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}, {Module: "keeper"}}}) b.put(t, buckets.Modules, "keeper", kmod) - // n1 is held in keys per module and node since the rows above, which a legacy key no longer moves. - bothPlaced, _ := meshmoduleassignerclient.DecodePlacement(both) - bothWrites, err := placeKeys.Writes("n1", &bothPlaced) + // n1 is held in keys per module and node, which the writes below move. + bothPlaced, _ := meshmoduleassignerclient.AssignmentsOf(both) + bothNode := bothPlaced["n1"] + bothWrites, err := placeKeys.Writes("n1", &bothNode) if err != nil { t.Fatal(err) } for _, w := range bothWrites { if w.Value != nil { - b.put(t, buckets.Placement, w.Key, w.Value) + b.put(t, buckets.Assignments, w.Key, w.Value) } } b.put(t, buckets.Wants, "want.n1.keeper", want) @@ -889,8 +892,8 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { for k, v := range c.wants { again.wants[k] = v } - for k, v := range c.placements { - again.placements[k] = v + for k, v := range c.assignments { + again.assignments[k] = v } for k, v := range c.records { again.records[k] = v @@ -912,7 +915,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Then the catalogue no longer places the node: it composes nothing of its own, and owes no pass either, so // no timer is armed again (rev-seed-same-3, round 1). again.mu.Lock() - clear(again.placements) + clear(again.assignments) again.mu.Unlock() if again.compose(ctx); again.owed.Load() { t.Errorf("%s: an unplaced node still owes a pass", row.name) @@ -1034,17 +1037,17 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if asked, _ := json.Marshal(sent["credentials"]); !strings.Contains(string(asked), `"role":"consumer"`) || !strings.Contains(string(asked), `"role":"provider"`) { t.Errorf("the vault was asked %s", asked) } - // ADR 0559 §4: the node answers placement candidates in shadow, placed or not, applying nothing and handing no - // composition: the same; a module the group adds, its secret not yet issued; a module removed; a placement it cannot + // ADR 0559 §4: the node answers assignment candidates in shadow, placed or not, applying nothing and handing no + // composition: the same; a module the group adds, its secret not yet issued; a module removed; a assignment it cannot // compose; a group whose keys went is no longer answered. What runs is never composed from a candidate: the // Composer does not read the state. for _, in := range New(env).inputs() { - if in.bucket == buckets.PlacementCandidates { - t.Fatal("the node's own composer reads placement candidates") + if in.bucket == buckets.ProposalCandidates { + t.Fatal("the node's own composer reads assignment candidates") } } sb := newPatternBus() - trunk := meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} + trunk := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} mods := map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}, "keeper": {Fp2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}} readOnly := &fakeBus{kv: map[string][]byte{buckets.Manifests + "/" + key: man, buckets.Manifests + "/" + kkey: kman}} @@ -1058,7 +1061,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { return json.RawMessage(`{"node":"n1","key":"sk","own":[]}`), nil // nothing issued for keeper: it is not placed here } // What n1 runs: lemurs and its own composition, as its composer composes them from the trunk. - running, _, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]meshmoduleassigner.Placement{"n1": trunk}, + running, _, _, err := frombus.Compose(ctx, fixedReader{assignments: map[string]meshmoduleassigner.Assignments{"n1": trunk}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}, modules: mods, manifests: map[string][]byte{digest: []byte(lemurs), kdigest: []byte(keeper)}}, frombus.Mesh{}, frombus.Scope{Node: "n1"}, map[string]string{"lemurs": fp}) if err != nil { @@ -1071,7 +1074,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } return out } - // The controller's compositions at n1's wants, in n1's composition bucket: what a head deleting its placement + // The controller's compositions at n1's wants, in n1's composition bucket: what a head deleting its assignment // returns it to (the seed's revert); here the ones it runs. composedBucket, _ := compositions.BucketFor("n1") for key, c := range running { @@ -1089,15 +1092,15 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { defer scancel() // Review of L2b, round 1, finding 2: the candidates' state is not there when the node starts (a delivery before the // indexer made it): the shadow tries again, and answers once it appears. - sb.missing(buckets.PlacementCandidates, true) + sb.missing(buckets.ProposalCandidates, true) kept := make(chan struct{}) go func() { sh.Keep(sctx, func(int) time.Duration { return 5 * time.Millisecond }); close(kept) }() time.Sleep(30 * time.Millisecond) - sb.missing(buckets.PlacementCandidates, false) + sb.missing(buckets.ProposalCandidates, false) sb.made(6) rec, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) - tp, _ := meshmoduleassignerclient.EncodePlacement(trunk) - sb.put(buckets.Placement, "placement.n1", tp, 1) + tp := assignedValues(t, trunk) + sb.putAssigned(tp, 1) sb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { raw, _ := meshcatalogclient.EncodeModule(m) @@ -1124,33 +1127,33 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { for _, m := range modules { p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m}) } - raw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: group, Placement: &p}) + raw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: group, Assignments: &p}) if err != nil { t.Fatal(err) } k := meshmoduleassigner.CandidatesByProposalNodeKey(group, "n1") - sb.put(buckets.PlacementCandidates, k, raw, rev) + sb.put(buckets.ProposalCandidates, k, raw, rev) } candidate("same", 3, "lemurs") candidate("adds", 4, "lemurs", "keeper") candidate("drops", 5) candidate("ghost", 6, "lemurs", "ghost") - // A head deleting n1's placement file (the seed's revert, mesh-catalog #526): n1 returns to the controller's + // A head deleting n1's assignment file (the seed's revert, mesh-catalog #526): n1 returns to the controller's // compositions, answered as such, not as every module removed. if raw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "deletes"}); err != nil { t.Fatal(err) } else { k := meshmoduleassigner.CandidatesByProposalNodeKey("deletes", "n1") - sb.put(buckets.PlacementCandidates, k, raw, 7) + sb.put(buckets.ProposalCandidates, k, raw, 7) } // A proposal that changes another node's key and reaches n1's composition (ADR 0567 §3): n1's candidate holds no - // placement of its own change (Reached, nil), and n1 answers against its current placement, never as one whose key + // assignment of its own change (Reached, nil), and n1 answers against its current assignment, never as one whose key // the proposal takes away. if raw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "reach", Reached: true}); err != nil { t.Fatal(err) } else { k := meshmoduleassigner.CandidatesByProposalNodeKey("reach", "n1") - sb.put(buckets.PlacementCandidates, k, raw, 8) + sb.put(buckets.ProposalCandidates, k, raw, 8) } sb.caught() var answers map[string]nodeengine.CandidateAnswer @@ -1199,7 +1202,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { return a.Verdict == nodeengine.CandidateAnswerVerdictSame && a.Returns == "" && a.Revision == 8 && len(a.Removed) == 0 && fmt.Sprint(a.Same) == "[lemurs node]" }, - // Issue 1126, A7: a candidate deleting the node's placement is refused, never answered as a return to the + // Issue 1126, A7: a candidate deleting the node's assignment is refused, never answered as a return to the // controller's compositions: no composition made elsewhere is applied, so the node would compose nothing. "deletes": func(a nodeengine.CandidateAnswer) bool { return a.Verdict == nodeengine.CandidateAnswerVerdictRefused && a.Returns == "" && a.Revision == 7 && @@ -1218,7 +1221,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { case <-time.After(100 * time.Millisecond): } // Issue 1126, 2026-10-28 (shanks answered probe/1126-seed-7 at revision 84 every 13-17 s): a candidate revision is - // answered once. A module's have whose health or words alone move (each look rewrites them), and a placement, a + // answered once. A module's have whose health or words alone move (each look rewrites them), and a assignment, a // module or a config value written again with the value it held, answer nothing again; a new revision answers its // group alone, and what runs moving answers every group once. answering := func() int { @@ -1226,7 +1229,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { defer readOnly.mu.Unlock() n := 0 for _, l := range readOnly.said { - if strings.HasPrefix(l, "answering placement candidate ") { + if strings.HasPrefix(l, "answering assignment candidate ") { n++ } } @@ -1255,7 +1258,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { settled := answering() lemursHave("its tool lemurs_health: no answer within 10s", 11) lemursHave("its tool lemurs_health: no answer within 20s", 12) - sb.put(buckets.Placement, "placement.n1", tp, 2) + sb.putAssigned(tp, 2) lraw, _ := meshcatalogclient.EncodeModule(mods["lemurs"]) sb.put(buckets.Modules, "lemurs", lraw, 2) drain() @@ -1279,7 +1282,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { } return "" } - if got := lastSaid("answering placement candidate same "); got != "answering placement candidate same (revision 8; woken by the candidate)" { + if got := lastSaid("answering assignment candidate same "); got != "answering assignment candidate same (revision 8; woken by the candidate)" { t.Errorf("a new revision's answer said %q", got) } settled = answering() @@ -1290,7 +1293,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if got := answering(); got != settled+len(answers) { t.Errorf("what runs moved, and %d answer(s) were composed again for %d group(s)", got-settled, len(answers)) } - if got := lastSaid("answering placement candidate drops "); got != "answering placement candidate drops (revision 5; woken by haves)" { + if got := lastSaid("answering assignment candidate drops "); got != "answering assignment candidate drops (revision 5; woken by haves)" { t.Errorf("an answer woken by what runs moving said %q", got) } // A read the bus did not answer is no answer to keep: the group is answered again after a bound, though nothing moved. @@ -1305,41 +1308,41 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { return a["adds"].Revision == 9 && a["adds"].Verdict == nodeengine.CandidateAnswerVerdictDiffers }) drain() - if got := lastSaid("answering placement candidate adds "); got != "answering placement candidate adds (revision 9; woken by a retry after a read the bus did not answer)" { + if got := lastSaid("answering assignment candidate adds "); got != "answering assignment candidate adds (revision 9; woken by a retry after a read the bus did not answer)" { t.Errorf("an answer composed again after the bus did not answer said %q", got) } k := meshmoduleassigner.CandidatesByProposalNodeKey("same", "n1") - sb.del(buckets.PlacementCandidates, k) + sb.del(buckets.ProposalCandidates, k) waitFor("the deleted group's answer stays", func(a map[string]nodeengine.CandidateAnswer) bool { _, ok := a["same"]; return !ok && len(a) == 5 }) // Review of L2b, round 2: a watch ends while the link stays; a group's key is deleted before the shadow watches // again. The new run reads afresh, so that group is no longer answered. - sb.missing(buckets.PlacementCandidates, true) + sb.missing(buckets.ProposalCandidates, true) sb.end(buckets.Modules) dropsKey := meshmoduleassigner.CandidatesByProposalNodeKey("drops", "n1") - sb.del(buckets.PlacementCandidates, dropsKey) + sb.del(buckets.ProposalCandidates, dropsKey) time.Sleep(30 * time.Millisecond) - sb.missing(buckets.PlacementCandidates, false) + sb.missing(buckets.ProposalCandidates, false) waitFor("a group deleted while the shadow was down is still answered", func(a map[string]nodeengine.CandidateAnswer) bool { _, ok := a["drops"] return !ok && len(a) == 4 }) - // Issue 1126, A7 AC1: a placement that cannot be read composes nothing and says why; the node never applies a - // composition made elsewhere instead. (A placement read without the node's key composes nothing either: above.) + // Issue 1126, A7 AC1: a assignment that cannot be read composes nothing and says why; the node never applies a + // composition made elsewhere instead. (A assignment read without the node's key composes nothing either: above.) { refused := newFakeBus() env := refused.env() watch := env.Watch env.Watch = func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) { - if bucket == buckets.Placement { + if bucket == buckets.Assignments { return nil, fmt.Errorf("nats: Permissions Violation for Subscription to %s", bucket) } return watch(ctx, bucket, pattern) } out, err := New(env).Watch(ctx, "") - if out != nil || err == nil || !strings.Contains(err.Error(), buckets.Placement) || !strings.Contains(err.Error(), "cannot be watched") { - t.Errorf("an unreadable placement: composed %v, said %v", out != nil, err) + if out != nil || err == nil || !strings.Contains(err.Error(), buckets.Assignments) || !strings.Contains(err.Error(), "cannot be watched") { + t.Errorf("an unreadable assignment: composed %v, said %v", out != nil, err) } } @@ -1359,20 +1362,20 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { defer ncancel() go func() { _ = nsh.Run(nctx) }() nb.made(6) - nb.put(buckets.Placement, "placement.n1", tp, 1) + nb.putAssigned(tp, 1) nb.put(buckets.Have, "n1.node", rec, 1) for name, m := range mods { raw, _ := meshcatalogclient.EncodeModule(m) nb.put(buckets.Modules, name, raw, 1) } - sameRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "same", Placement: &trunk}) + sameRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "same", Assignments: &trunk}) sameKey := meshmoduleassigner.CandidatesByProposalNodeKey("same", "n1") - nb.put(buckets.PlacementCandidates, sameKey, sameRaw, 3) + nb.put(buckets.ProposalCandidates, sameKey, sameRaw, 3) // Issue 1126, T3: an older group (a closed pull request's candidate, rev 2) is answered after the newest. - oldRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "older", Placement: &trunk}) + oldRaw, _ := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "older", Assignments: &trunk}) oldKey := meshmoduleassigner.CandidatesByProposalNodeKey("older", "n1") - nb.put(buckets.PlacementCandidates, oldKey, oldRaw, 2) - for _, bucket := range []string{buckets.PlacementCandidates, buckets.Placement, buckets.Have, buckets.Modules, builds.BuilderBuildsBucket} { + nb.put(buckets.ProposalCandidates, oldKey, oldRaw, 2) + for _, bucket := range []string{buckets.ProposalCandidates, buckets.Assignments, buckets.Have, buckets.Modules, builds.BuilderBuildsBucket} { nb.send(bucket, agent.Update{Caught: true}) } select { @@ -1384,7 +1387,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { t.Fatal("a shadow not ready after its wait answered nothing") } nmu.Lock() - if !strings.Contains(strings.Join(nsaid, "\n"), "this node answers no placement candidate yet: the node has not read "+ResolvedBucket) { + if !strings.Contains(strings.Join(nsaid, "\n"), "this node answers no assignment candidate yet: the node has not read "+ResolvedBucket) { t.Errorf("a shadow not ready said %q", nsaid) } nmu.Unlock() @@ -1408,13 +1411,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // Issue 1126, T2: an answer says when it starts and when it ends (and, every minute between, what it waits on). nmu.Lock() said := strings.Join(nsaid, "\n") - for _, line := range []string{"the answer to same took", "answering placement candidate same (revision 3; woken by its first answer since the shadow began)", - "answered placement candidate same in", "answering placement candidate older (revision 2;"} { + for _, line := range []string{"the answer to same took", "answering assignment candidate same (revision 3; woken by its first answer since the shadow began)", + "answered assignment candidate same in", "answering assignment candidate older (revision 2;"} { if !strings.Contains(said, line) { t.Errorf("%q was not said: %q", line, nsaid) } } - if strings.Index(said, "answering placement candidate same") > strings.Index(said, "answering placement candidate older") { + if strings.Index(said, "answering assignment candidate same") > strings.Index(said, "answering assignment candidate older") { t.Errorf("the older group was answered first: %q", nsaid) } nmu.Unlock() @@ -1454,7 +1457,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { // trunk's. inOwnRunning := []compositions.Composed{{Node: "n1", Module: "lemurs", FP: fp}} other := "src2:" + strings.Repeat("9", 64) - gotFps := shadowBuilds(inOwnRunning, meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "nats"}, {Module: "keeper"}}}, + gotFps := shadowBuilds(inOwnRunning, meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "nats"}, {Module: "keeper"}}}, map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Fp2: other}, "nats": {Fp2: other}, "keeper": {Fp2: other}}, []string{"nats"}) if fmt.Sprint(gotFps) != fmt.Sprint(map[string]string{"lemurs": fp, "keeper": other}) { t.Errorf("shadow builds %v; want lemurs at what runs, keeper at the trunk's, nats at none (folded)", gotFps) @@ -1519,12 +1522,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { cc.manifests[cdigest] = []byte(withCert) cc.modules = map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}} cc.records["n1"] = nodeengine.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))} - onlyLemurs := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} + onlyLemurs := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}} cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs) if certAsks != 0 { t.Errorf("no module asks for a certificate, and the vault was asked %d times", certAsks) } - withWeb := meshmoduleassigner.Placement{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "web"}}} + withWeb := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "web"}}} cc.certifyIfWanted(context.Background(), reader{c: cc}, withWeb) cc.certifyIfWanted(context.Background(), reader{c: cc}, withWeb) if certAsks != 1 { @@ -1665,14 +1668,14 @@ func (b *patternBus) caught() { // fixedReader is frombus's Reader over values held, for composing what a node runs. type fixedReader struct { - placements map[string]meshmoduleassigner.Placement - records map[string]nodeengine.NodeRecord - modules map[string]meshcatalog.ModulesByModuleValue - manifests map[string][]byte + assignments map[string]meshmoduleassigner.Assignments + records map[string]nodeengine.NodeRecord + modules map[string]meshcatalog.ModulesByModuleValue + manifests map[string][]byte } -func (r fixedReader) Placements(context.Context) (map[string]meshmoduleassigner.Placement, error) { - return r.placements, nil +func (r fixedReader) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) { + return r.assignments, nil } func (r fixedReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) { return r.records, nil @@ -1791,18 +1794,18 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { if err != nil { t.Fatal(err) } - place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) - b.put(t, buckets.Placement, "placement.n1", place) + b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Modules, "lemurs", mod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } handed := map[string]bool{} @@ -1872,18 +1875,18 @@ func TestAHeldOwnCompositionThatWasHandedIsNotDropped(t *testing.T) { if err != nil { t.Fatal(err) } - place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, + place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) - b.put(t, buckets.Placement, "placement.n1", place) + b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Modules, "lemurs", mod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } // The list is read: the node's own composition is handed. Then the reader cannot act again (it restarted and has @@ -1999,13 +2002,13 @@ func TestTheHolderOfTheCatalogIsStartedFromTheOwnCompositionBeforeTheListIsReadA b.put(t, buckets.Have, hk, hv) placed = append(placed, meshmoduleassigner.PlacedModule{Module: m}) } - place, _ := meshmoduleassignerclient.EncodePlacement(meshmoduleassigner.Placement{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: placed}) + place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: placed}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true}) - b.put(t, buckets.Placement, "placement.n1", place) + b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Wants, "want.n1.node", nodeWant) - for _, bucket := range []string{buckets.Wants, buckets.Placement, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { + for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { b.send(bucket, agent.Update{Caught: true}) } servicesIn := func(u agent.Update) []string { @@ -2056,3 +2059,28 @@ func TestACompositionHoldsWhenTheSeatListReaderCouldNotStart(t *testing.T) { t.Errorf("held %v, comps %v: a reader that did not start must hold the node's own composition", held, comps) } } + +// assignedValues is every key the assigner writes for p: the node's, `.node`, and each module's, `.`. +func assignedValues(t *testing.T, p meshmoduleassigner.Assignments) map[string][]byte { + t.Helper() + vals, err := meshmoduleassignerclient.AssignmentsValues(p) + if err != nil { + t.Fatal(err) + } + return vals +} + +// putAssigned writes the keys of one node's assignments into the assignments state. +func (b *fakeBus) putAssigned(t *testing.T, vals map[string][]byte) { + t.Helper() + for _, k := range slices.Sorted(maps.Keys(vals)) { + b.put(t, buckets.Assignments, k, vals[k]) + } +} + +// putAssigned sends the keys of one node's assignments, each at the revision. +func (b *patternBus) putAssigned(vals map[string][]byte, rev uint64) { + for _, k := range slices.Sorted(maps.Keys(vals)) { + b.put(buckets.Assignments, k, vals[k], rev) + } +} diff --git a/internal/selfcompose/shadow.go b/internal/selfcompose/shadow.go index abc73a1..4e8e466 100644 --- a/internal/selfcompose/shadow.go +++ b/internal/selfcompose/shadow.go @@ -6,10 +6,10 @@ import ( "encoding/json" "fmt" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/placement/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "github.com/novox/mesh-host/internal/buckets" "github.com/novox/mesh-host/internal/compose/frombus" engcomp "github.com/novox/mesh-host/internal/compositions" @@ -23,13 +23,13 @@ import ( "github.com/novox/mesh-host/internal/agent" ) -// Shadow is the node's answer to placement candidates (novox/hq ADR 0559 §4, issue 1126): for every proposal with a key in -// the placement seat's state `candidates`, it composes this node in shadow from the trunk's placement with the proposal's +// Shadow is the node's answer to assignment candidates (novox/hq ADR 0559 §4, issue 1126): for every proposal with a key in +// the assignment seat's state `candidates`, it composes this node in shadow from the trunk's assignment with the proposal's // keys laid over it and its own inputs (the sealed values and boxes it holds among them), compares the result with what // the node runs, and hands the answers to be written into the node's own have key, `.node`, under `candidates`. -// It applies nothing and hands no composition: what runs is still composed from `placement` alone, by the Composer. +// It applies nothing and hands no composition: what runs is still composed from `assignment` alone, by the Composer. // -// **Event-driven, like the Composer.** It watches every candidate key, the trunk's placement, every node's record, the +// **Event-driven, like the Composer.** It watches every candidate key, the trunk's assignment, every node's record, the // indexer's modules, this node's config values, and this node's module haves (what it runs moved); a burst of changes // answers once. It runs whether or not the node is placed yet, against what it runs. An answer is handed only when it changes, and a proposal's answer is dropped when its // keys go. @@ -72,7 +72,7 @@ type answeredAt struct { } // inputWords name the shadow's inputs, in Run's order, as an answer says which of them woke it. -var inputWords = [...]string{"the candidate", "placement", "records", "modules", "values", "haves"} +var inputWords = [...]string{"the candidate", "assignment", "records", "modules", "values", "haves"} // wokenBy says what moved between the answer a proposal stands at (had, when done) and the one about to be composed (at): // its first answer, a retry, its candidate's revisions, or the inputs that changed, by their words. @@ -130,7 +130,7 @@ func (s *Shadow) Keep(ctx context.Context, backoff func(try int) time.Duration) why = err.Error() } if why != said { - s.c.env.Say("this node answers no placement candidate for now, and tries again: " + why) + s.c.env.Say("this node answers no assignment candidate for now, and tries again: " + why) said = why } select { @@ -161,8 +161,8 @@ func (s *Shadow) Run(ctx context.Context) error { node := c.env.Node // In inputWords' order. ins := []input{ - {buckets.PlacementCandidates, ">", nil, nil}, - {buckets.Placement, ">", (*Composer).takePlacement, nil}, + {buckets.ProposalCandidates, ">", nil, nil}, + {buckets.Assignments, ">", (*Composer).takeAssignments, nil}, {buckets.Have, "*." + nodeengineclient.NodeWord, nil, nil}, {buckets.Modules, ">", (*Composer).takeModule, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, @@ -178,7 +178,7 @@ func (s *Shadow) Run(ctx context.Context) error { for i, in := range ins { ch, err := c.env.Watch(ctx, in.bucket, in.pattern) if err != nil { - return fmt.Errorf("the node answers placement candidates from %s, which cannot be watched: %w", in.bucket, err) + return fmt.Errorf("the node answers assignment candidates from %s, which cannot be watched: %w", in.bucket, err) } go func(i int, ch <-chan agent.Update) { for u := range ch { @@ -216,7 +216,7 @@ func (s *Shadow) Run(ctx context.Context) error { continue case t := <-merged: if !t.ok { - return fmt.Errorf("the watch of %s ended; the node answers no placement candidate until its agent starts again", ins[t.in].bucket) + return fmt.Errorf("the watch of %s ended; the node answers no assignment candidate until its agent starts again", ins[t.in].bucket) } // Only a change of what an answer is composed from answers again: a key written again with the value it held, // this node's own key (its answers), or a module's have whose health or words moved but not what it runs, is @@ -258,7 +258,7 @@ func (s *Shadow) Run(ctx context.Context) error { if s.owed { wait := LaterWait(tries) tries++ - c.env.Say(fmt.Sprintf("this node answers again in %s the placement candidates whose answer met a bus that did not answer", wait)) + c.env.Say(fmt.Sprintf("this node answers again in %s the assignment candidates whose answer met a bus that did not answer", wait)) again = time.After(wait) } else { tries, again = 0, nil @@ -300,7 +300,7 @@ func (s *Shadow) takeCandidate(u agent.Update) bool { return true } c, err := meshmoduleassigner.DecodeCandidatesByProposalNodeValue(u.Value) - if err != nil || c.Proposal != proposal || (c.Placement != nil && c.Placement.Node != node) { + if err != nil || c.Proposal != proposal || (c.Assignments != nil && c.Assignments.Node != node) { // Not one: read as gone. if _, ok := s.candidates[proposal][node]; ok { delete(s.candidates[proposal], node) @@ -345,7 +345,7 @@ func (s *Shadow) notReady(ctx context.Context, ins []input, caught []bool) { } } why := fmt.Sprintf("the node has not read %s after %s; it answers once it has", strings.Join(waits, ", "), notReadyAfter) - s.c.env.Say("this node answers no placement candidate yet: " + why) + s.c.env.Say("this node answers no assignment candidate yet: " + why) s.mu.Lock() next := map[string]nodeengine.CandidateAnswer{} for proposal, keys := range s.candidates { @@ -428,7 +428,7 @@ func (s *Shadow) answerAll(ctx context.Context) { given[g] = a } s.answer(ctx, given) - c.env.Say(fmt.Sprintf("%s answered %d placement candidate proposal(s)", node, len(given))) + c.env.Say(fmt.Sprintf("%s answered %d assignment candidate proposal(s)", node, len(given))) } } for _, l := range proposals { @@ -444,7 +444,7 @@ func (s *Shadow) answerAll(ctx context.Context) { continue } began := time.Now() - c.env.Say(fmt.Sprintf("answering placement candidate %s (revision %d; woken by %s)", proposal, l.rev, wokenBy(at, done, retry, l.at))) + c.env.Say(fmt.Sprintf("answering assignment candidate %s (revision %d; woken by %s)", proposal, l.rev, wokenBy(at, done, retry, l.at))) stop := make(chan struct{}) go func() { tick := time.NewTicker(progressEvery) @@ -474,10 +474,10 @@ func (s *Shadow) answerAll(ctx context.Context) { if ok { a = judge.FitAnswer(a, engcomp.MaxAnswerBytes) next[proposal] = a - c.env.Say(fmt.Sprintf("answered placement candidate %s in %s: %s", proposal, took.Round(time.Millisecond), a.Verdict)) + c.env.Say(fmt.Sprintf("answered assignment candidate %s in %s: %s", proposal, took.Round(time.Millisecond), a.Verdict)) } else { delete(next, proposal) - c.env.Say(fmt.Sprintf("placement candidate %s does not place this node: nothing to answer", proposal)) + c.env.Say(fmt.Sprintf("assignment candidate %s does not place this node: nothing to answer", proposal)) } s.mu.Lock() if provisional { @@ -507,12 +507,12 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengin defer c.waiting("composing")() s.mu.Lock() keys := s.candidates[proposal] - given := make(map[string]*meshmoduleassigner.Placement, len(keys)) + given := make(map[string]*meshmoduleassigner.Assignments, len(keys)) for n, k := range keys { - if k.c.Reached && k.c.Placement == nil { + if k.c.Reached && k.c.Assignments == nil { continue // reached, with no key of its own: nothing laid over it } - given[n] = k.c.Placement + given[n] = k.c.Assignments } // The revision answered is the node's own candidate key's; a node with none is not asked. own, mine := keys[node] @@ -534,26 +534,26 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengin running = c.env.Running() } if p, ok := given[node]; ok && p == nil && !own.c.Reached { - // The proposal deletes this node's placement key: the node would compose nothing and keep running what it runs, + // The proposal deletes this node's assignment key: the node would compose nothing and keep running what it runs, // since no composition made elsewhere is applied (issue 1126: the controller ended by its unassignment). Refused, - // so the catalogue does not take a node's placement away by a merge. + // so the catalogue does not take a node's assignment away by a merge. return nodeengine.CandidateAnswer{Proposal: proposal, Revision: int64(rev), Verdict: nodeengine.CandidateAnswerVerdictRefused, - Refused: "the proposal takes this node's placement away: it then composes nothing, and no composition made " + + Refused: "the proposal takes this node's assignment away: it then composes nothing, and no composition made " + "elsewhere is applied, so what runs here would be followed by nothing"}, true } - placement := given[node] - if placement == nil { + assignment := given[node] + if assignment == nil { c.mu.Lock() - p := c.placements[node] + p := c.assignments[node] c.mu.Unlock() - placement = &p + assignment = &p } // What it runs inside its own composition, of what the candidate places (the seed's trunk places nothing). c.mu.Lock() - inOwn := c.inOwnOf(*placement) + inOwn := c.inOwnOf(*assignment) c.mu.Unlock() - fps := shadowBuilds(running, *placement, modules, inOwn) + fps := shadowBuilds(running, *assignment, modules, inOwn) kept := map[string]string{} for module, fp := range fps { if mod, ok := modules[module]; ok && mod.Fp2 == fp { @@ -570,7 +570,7 @@ func (s *Shadow) answerProposal(ctx context.Context, proposal string) (nodeengin c.mu.Lock() c.wants = held c.mu.Unlock() - a := frombus.Answer(ctx, reader{c: c}, c.env.Mesh, frombus.Scope{Node: node, KeptAt: kept, Placements: given, InOwn: inOwn, + a := frombus.Answer(ctx, reader{c: c}, c.env.Mesh, frombus.Scope{Node: node, KeptAt: kept, Assignments: given, InOwn: inOwn, SameValue: c.sameValue, HeldToday: s.heldToday()}, fps, running, proposal, rev) s.mu.Lock() ran := make(map[string]string, len(s.ran)) @@ -664,7 +664,7 @@ func asHavesSay(a nodeengine.CandidateAnswer, ran map[string]string) nodeengine. // of the old form) is given none, so it is folded there again, as the node's own composer folds it, rather than // composed at the trunk's build as if the candidate added it (issue 1126: nats and photos have a trunk build that no // want names). -func shadowBuilds(running []engcomp.Composed, placement meshmoduleassigner.Placement, modules map[string]meshcatalog.ModulesByModuleValue, inOwn []string) map[string]string { +func shadowBuilds(running []engcomp.Composed, assignment meshmoduleassigner.Assignments, modules map[string]meshcatalog.ModulesByModuleValue, inOwn []string) map[string]string { fps := map[string]string{} for _, r := range running { if r.Module != "" && r.FP != "" { @@ -675,7 +675,7 @@ func shadowBuilds(running []engcomp.Composed, placement meshmoduleassigner.Place for _, m := range inOwn { folded[m] = true } - for _, m := range placement.Modules { + for _, m := range assignment.Modules { if _, runs := fps[m.Module]; !runs && !folded[m.Module] { if mod, ok := modules[m.Module]; ok && mod.Fp2 != "" { fps[m.Module] = mod.Fp2 diff --git a/testdata/beside/mesh-networking/modules/route-proxy/module.json.captured b/testdata/beside/mesh-networking/modules/route-proxy/module.json.captured index 668ab88..b4055ab 100644 --- a/testdata/beside/mesh-networking/modules/route-proxy/module.json.captured +++ b/testdata/beside/mesh-networking/modules/route-proxy/module.json.captured @@ -265,7 +265,7 @@ ] }, "reads": [ - "mesh-delivery-assigner.placement", + "mesh-delivery-assigner.assignments", "mesh-provisioner.plans", "mesh-provisioner.provisions" ] -- 2.54.0 From c1b8b9d115b96c2a2501cb3550a0841d2a4960e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 22:33:21 +0100 Subject: [PATCH 58/73] The composition pass never waits on marking haves, and the compose verb answers while a pass runs The marks of the haves (a refusal, the words a module keeps) read and write each module's have in turn, and each write walks every have: on a slow bus the pass waited minutes on them (shanks, 2026-11-05 22:22). They now run off the pass, newest word per kind, each bounded; an ask of compose during a pass is answered at once and queues one more pass. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/marker.go | 76 +++++++++++++++++++ cmd/mesh-host/marker_test.go | 47 ++++++++++++ cmd/mesh-host/nodeagent.go | 7 +- .../selfcompose/compose_while_passing_test.go | 75 ++++++++++++++++++ internal/selfcompose/selfcompose.go | 20 ++++- 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 cmd/mesh-host/marker.go create mode 100644 cmd/mesh-host/marker_test.go create mode 100644 internal/selfcompose/compose_while_passing_test.go diff --git a/cmd/mesh-host/marker.go b/cmd/mesh-host/marker.go new file mode 100644 index 0000000..2f5cbd7 --- /dev/null +++ b/cmd/mesh-host/marker.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "sync" + "time" +) + +// markWithin bounds one marking of the haves (what the composer tells its agent: a refusal, the words it keeps). The marks +// read and write each module's have over the bus one after the other; a bus that answers slowly or not at all must hold +// the marking, never the composition pass (issue 1965, shanks 2026-11-05 22:22: a pass waited minutes on them). +var markWithin = 45 * time.Second + +// marker runs the composer's marks of the haves off the composition pass: set returns at once, one worker runs what was +// set last under each name, in the order the names were first set, each within markWithin. A mark set while one runs +// replaces the pending mark of its name, so the haves are marked once for the newest word, not once for every pass. +type marker struct { + run context.Context + + mu sync.Mutex + pending map[string]func(context.Context) + order []string + working bool + idle *sync.Cond +} + +func newMarker(run context.Context) *marker { + m := &marker{run: run, pending: map[string]func(context.Context){}} + m.idle = sync.NewCond(&m.mu) + return m +} + +// set takes the mark name should carry; it never waits on the bus. +func (m *marker) set(name string, mark func(context.Context)) { + m.mu.Lock() + defer m.mu.Unlock() + if _, queued := m.pending[name]; !queued { + m.order = append(m.order, name) + } + m.pending[name] = mark + if m.working { + return + } + m.working = true + go m.work() +} + +func (m *marker) work() { + for { + m.mu.Lock() + if len(m.order) == 0 || m.run.Err() != nil { + m.order, m.pending = nil, map[string]func(context.Context){} + m.working = false + m.idle.Broadcast() + m.mu.Unlock() + return + } + name := m.order[0] + m.order = m.order[1:] + mark := m.pending[name] + delete(m.pending, name) + m.mu.Unlock() + ctx, cancel := context.WithTimeout(m.run, markWithin) + mark(ctx) + cancel() + } +} + +// wait returns once no mark is pending or running (for tests). +func (m *marker) wait() { + m.mu.Lock() + defer m.mu.Unlock() + for m.working { + m.idle.Wait() + } +} diff --git a/cmd/mesh-host/marker_test.go b/cmd/mesh-host/marker_test.go new file mode 100644 index 0000000..68c86e5 --- /dev/null +++ b/cmd/mesh-host/marker_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "testing" + "time" +) + +// Issue 1965 (shanks 2026-11-05 22:22): the marks of the haves never hold the composition pass. A mark that stalls on the +// bus is cut at markWithin, a mark set while one runs replaces the pending one of its name, and set itself never waits. +func TestMarksOfTheHavesNeverHoldThePass(t *testing.T) { + setMarkWithin(t, 30*time.Millisecond) + m := newMarker(context.Background()) + var ran []string + stalled := make(chan struct{}) + began := time.Now() + m.set("refused", func(ctx context.Context) { // a have write that stalls until its bound + close(stalled) + <-ctx.Done() + ran = append(ran, "refused-stalled") + }) + <-stalled + // Set while the first runs: only the newest of a name is kept. + m.set("kept", func(context.Context) { ran = append(ran, "kept-old") }) + m.set("kept", func(context.Context) { ran = append(ran, "kept-new") }) + m.set("refused", func(context.Context) { ran = append(ran, "refused-new") }) + if took := time.Since(began); took > 20*time.Millisecond { + t.Errorf("setting marks took %v; it must not wait on the stalled one", took) + } + m.wait() + want := []string{"refused-stalled", "kept-new", "refused-new"} + if len(ran) != len(want) { + t.Fatalf("ran %v; want %v", ran, want) + } + for i := range want { + if ran[i] != want[i] { + t.Fatalf("ran %v; want %v", ran, want) + } + } +} + +func setMarkWithin(t *testing.T, d time.Duration) { + t.Helper() + old := markWithin + markWithin = d + t.Cleanup(func() { markWithin = old }) +} diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 2ee61ae..4842db6 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -586,6 +586,8 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus // The agent of this run alone: its composer tells it, never the agent of a run begun since (issue 1126: a pass in // flight at the switch marked the next agent's modules failed). own := &agentNow{} + // The marks of the haves run off the composition pass, each bounded (see marker). + marks := newMarker(run) hooks := composerHooks{ running: func() []delivery.Composed { if now := own.get(); now != nil { @@ -609,13 +611,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus }, refused: func(why string, held map[string]string) { if now := own.get(); now != nil && run.Err() == nil { - now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126) + // A module held alone marks that module, never the others (issue 1126); the marking never holds the pass. + marks.set("refused", func(ctx context.Context) { now.NotComposedEach(ctx, why, held) }) } }, // And what it keeps of a module as it runs, for a manifest that does not say it yet: on the module's have (ADR 0601 §4). kept: func(kept map[string][]string) { if now := own.get(); now != nil && run.Err() == nil { - now.KeptNow(run, kept) + marks.set("kept", func(ctx context.Context) { now.KeptNow(ctx, kept) }) } }, // And the vault's authority beside the controller's, and this node's certificate asked of the vault when a diff --git a/internal/selfcompose/compose_while_passing_test.go b/internal/selfcompose/compose_while_passing_test.go new file mode 100644 index 0000000..234d1ef --- /dev/null +++ b/internal/selfcompose/compose_while_passing_test.go @@ -0,0 +1,75 @@ +package selfcompose + +import ( + "context" + "strings" + "sync/atomic" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/agent" +) + +// Issue 1965 (shanks 2026-11-05 22:27: the verb compose gave no answer in 30 s while a pass waited on the bus): an ask +// that comes while a pass runs is answered at once, says one more pass is queued and what the running one waits on, and +// the queued pass is made when the running one ends. +func TestComposeAnsweredWhileAPassRuns(t *testing.T) { + b := newFakeBus() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + env := b.env() + hold := make(chan struct{}) + var passes atomic.Int64 + reached := make(chan struct{}, 8) + env.Running = func() []delivery.Composed { + passes.Add(1) + reached <- struct{}{} + if passes.Load() == 1 { + <-hold // the first pass waits on the bus + } + return nil + } + c := New(env) + out, err := c.Watch(ctx, "n1.>") + if err != nil { + t.Fatal(err) + } + have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) + place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}) + nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) + b.put(t, delivery.PlacementBucket, "placement.n1", place) + b.put(t, delivery.HaveBucket, "n1.node", have) + b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) + for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { + b.send(bucket, agent.Update{Caught: true}) + } + go func() { + for range out { + } + }() + select { + case <-reached: + case <-time.After(5 * time.Second): + t.Fatal("no pass began") + } + answered := make(chan Pass, 1) + go func() { + p, _ := c.ComposeNow(ctx) + answered <- p + }() + select { + case p := <-answered: + if !strings.Contains(p.Queued, "a pass is running") { + t.Errorf("answered %+v; want it to say a pass is running and one is queued", p) + } + case <-time.After(2 * time.Second): + t.Fatal("the verb compose gave no answer while a pass ran") + } + close(hold) + select { + case <-reached: // the queued pass + case <-time.After(5 * time.Second): + t.Fatal("the queued pass was not made when the running one ended") + } +} diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 6607fc6..1d359f3 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -167,6 +167,8 @@ type Composer struct { wait time.Duration // asked are the compose verb's asks (ComposeNow), each answered once the pass it asks is made. asked chan chan Pass + // passing is set while a pass is made. + passing atomic.Bool // last is how the last pass went, under mu. last Pass // watching says the composer's loop runs (Watch), so an ask of a pass can be answered. @@ -216,6 +218,9 @@ type Pass struct { Again time.Time `json:"again,omitempty"` // Earlier says the answer is the last pass, made less than askedWithin ago, and no new one was made for the ask. Earlier bool `json:"earlier,omitempty"` + // Queued says a pass was running when the ask came: one more is made when it ends, and this answer is not its result. + // It says what the running pass waits on and since when. + Queued string `json:"queued,omitempty"` } // LastHeld is what the last pass held, each module with why; nil before a pass or when it held none. @@ -307,6 +312,16 @@ func (c *Composer) ComposeNow(ctx context.Context) (Pass, error) { return last, nil } reply := make(chan Pass, 1) + if c.passing.Load() { + // A pass runs: the ask joins the one made when it ends (all asks of the time share it), and is answered now, never + // held for the pass (issue 1965: the verb gave no answer for minutes while a pass waited on the bus). + select { + case c.asked <- reply: + default: // eight asks already wait for that pass + } + last.Queued = "a pass is running and waits on " + c.waitingOn() + "; one more is made when it ends" + return last, nil + } select { case c.asked <- reply: case <-ctx.Done(): @@ -496,7 +511,10 @@ func (c *Composer) Watch(ctx context.Context, _ string) (<-chan agent.Update, er continue } dirty = false - for _, u := range c.compose(ctx) { + c.passing.Store(true) + updates := c.compose(ctx) + c.passing.Store(false) + for _, u := range updates { select { case out <- u: case <-ctx.Done(): -- 2.54.0 From c55672ce5ea0208d759e7729f125f77eabdf773c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 23:03:58 +0100 Subject: [PATCH 59/73] A fill while the agent restarts is judged by the last view held and, refused, is owed and told again (issue 924) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/nodeagent.go | 37 +++++++++++++++++++++++--- cmd/mesh-host/whatruns_test.go | 45 ++++++++++++++++++++++++++++++++ internal/filling/filling.go | 15 ++++++++++- internal/filling/filling_test.go | 8 ++++++ 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 812c122..a1b4568 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -699,14 +699,36 @@ var reloadSettles = 10 * time.Second type agentNow struct { mu sync.Mutex ag *agent.Agent + // last is the view of what runs the previous agent held when it stopped, kept in memory: while the next agent has + // read no haves yet, what runs is this and never the retired kept declaration (a bus link that drops restarts the + // agent, and every fill in that window was refused; issue 924). + last *runsView +} + +// runsView is what an agent said runs, to be read after the agent stopped. +type runsView struct { + everything []compositions.Composed + holdsNode bool } func (c *agentNow) set(a *agent.Agent) { c.mu.Lock() + if a == nil && c.ag != nil { + if v := (&runsView{c.ag.Everything(), c.ag.HoldsNode()}); len(v.everything) > 0 || v.holdsNode { + c.last = v + } + } c.ag = a c.mu.Unlock() } +// lastView is the view the last agent held when it stopped, nil when none held any. +func (c *agentNow) lastView() *runsView { + c.mu.Lock() + defer c.mu.Unlock() + return c.last +} + func (c *agentNow) get() *agent.Agent { c.mu.Lock() defer c.mu.Unlock() @@ -890,11 +912,20 @@ func declaredNow(statePath string, signer ed25519.PublicKey) (*declaration.Decla func runsFromAgent(current *agentNow) func(string, ed25519.PublicKey) (*declaration.Declaration, error) { return func(statePath string, signer ed25519.PublicKey) (*declaration.Declaration, error) { kept := func() (*declaration.Declaration, error) { return keptDeclaration(statePath, signer) } - ag := current.get() - if ag == nil { + var view *runsView + if ag := current.get(); ag != nil { + view = &runsView{ag.Everything(), ag.HoldsNode()} + } + // An agent that has read no haves yet, or none: the last view held, never the retired kept declaration. + if view == nil || (len(view.everything) == 0 && !view.holdsNode) { + if last := current.lastView(); last != nil { + view = last + } + } + if view == nil { return kept() } - return whatRuns(ag.Everything(), ag.HoldsNode(), kept) + return whatRuns(view.everything, view.holdsNode, kept) } } diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index fbead93..8d0a787 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -10,6 +10,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" engcomp "github.com/novox/mesh-host/internal/compositions" + "os" "path/filepath" "sort" "strings" @@ -19,6 +20,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/agent" + "github.com/novox/mesh-host/internal/filling" "github.com/novox/mesh-host/internal/liveness" "github.com/novox/mesh-host/internal/store" ) @@ -237,3 +239,46 @@ func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) { t.Errorf("a resource sharing only its target was dropped: %v", dropped) } } + +// Issue 924: the bus link drops, the agent restarts, and the filling is asked while it holds nothing yet. What runs is +// then the last view the node held, never the retired kept declaration; and a fill refused for want of any is owed and +// told once the agent holds the node again, so every file is written afterwards. +func TestAFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { + keptRetired.Store(true) + defer keptRetired.Store(false) + node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Provisioner: "mesh-node-provisioner"}} + files := []engcomp.Composed{{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}, Filled: []string{"/tmp/r6/db/a"}}} + view := &runsView{everything: append(files, node), holdsNode: true} + + var placed []string + current := &agentNow{} + e := &filling.Engine{ + Declared: func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) }, + UIDOf: func(string) (uint32, error) { return 4242, nil }, + Lock: func() (func(), error) { return func() {}, nil }, + Place: func(_ context.Context, _ *declaration.Declaration, path string, _ []byte) (string, string, error) { + placed = append(placed, path) + return "updated", "d", nil + }, + } + fill := func() filling.Answer { + return e.Write(context.Background(), 4242, filling.Request{Path: "/tmp/r6/db/a", Content: []byte("x")}) + } + socket := filepath.Join(t.TempDir(), "sock") + if err := os.WriteFile(socket, nil, 0o600); err != nil { + t.Fatal(err) + } + + // The node never held a view (a cold start): refused, and owed. + if a := fill(); a.Failed == "" && a.Refused == "" { + t.Fatalf("a fill with nothing read was accepted: %+v", a) + } + // The agent stopped holding the node: the last view it held answers, and the fill is written. + current.last = view + if told, err := e.Knock(socket); err != nil || told == "" { + t.Fatalf("the refused fill was not owed and told: %q, %v", told, err) + } + if a := fill(); a.Failed != "" || a.Refused != "" || len(placed) != 1 { + t.Fatalf("the fill during the restart was not written: %+v, placed %v", a, placed) + } +} diff --git a/internal/filling/filling.go b/internal/filling/filling.go index 4954d78..fd98e58 100644 --- a/internal/filling/filling.go +++ b/internal/filling/filling.go @@ -100,6 +100,13 @@ type owed struct { unnamed map[string]bool // refused: the declaration did not name it as filled failed map[string]bool // attempted, and the write or what reads it failed noAccount bool // refused: the declaration named no provisioner account + unread bool // refused: the declaration could not be read (a restart of the agent, a bus that is gone) +} + +func (o *owed) setUnread() { + o.mu.Lock() + o.unread = true + o.mu.Unlock() } func (o *owed) add(kind *map[string]bool, path string) { @@ -115,6 +122,7 @@ func (o *owed) add(kind *map[string]bool, path string) { func (e *Engine) Accepts(uid uint32) error { d, err := e.Declared() if err != nil { + e.owed.setUnread() return fmt.Errorf("this node's declaration cannot be read, so no write is accepted: %w", err) } if err := JudgeAccount(d, uid, e.UIDOf); err != nil { @@ -147,6 +155,7 @@ func (e *Engine) Write(ctx context.Context, uid uint32, req Request) Answer { defer unlock() d, err := e.Declared() if err != nil { + e.owed.setUnread() return Refusal(req.Path, "this node's declaration cannot be read, so nothing was written: "+err.Error()) } if err := JudgeAccount(d, uid, e.UIDOf); err != nil { @@ -184,7 +193,7 @@ func (e *Engine) Knock(socket string) (string, error) { } defer unlock() e.owed.mu.Lock() - nothing := len(e.owed.unnamed) == 0 && len(e.owed.failed) == 0 && !e.owed.noAccount + nothing := len(e.owed.unnamed) == 0 && len(e.owed.failed) == 0 && !e.owed.noAccount && !e.owed.unread e.owed.mu.Unlock() if nothing { return "", nil @@ -206,6 +215,10 @@ func (e *Engine) Knock(socket string) (string, error) { why = append(why, p+" (its write failed)") delete(e.owed.failed, p) } + if e.owed.unread { + why = append(why, "every file (the declaration can be read now)") + e.owed.unread = false + } if e.owed.noAccount && d.Provisioner != "" { why = append(why, "every file (the provisioner's account is named now)") e.owed.noAccount = false diff --git a/internal/filling/filling_test.go b/internal/filling/filling_test.go index c50fc34..b782836 100644 --- a/internal/filling/filling_test.go +++ b/internal/filling/filling_test.go @@ -118,7 +118,12 @@ func TestAnUndeclaredPathIsRefused(t *testing.T) { after string // the declaration applied afterwards knock string // what the knock names; "" for none twice bool // a second knock names nothing + // the declaration could not be read when the write came (the agent restarts): owed, and told once it can be + unreadable bool }{ + {name: "a write refused while the declaration could not be read", before: withAccount, unreadable: true, + account: provisionerUID, path: "/etc/m/m.conf", after: withAccount, + knock: "every file (the declaration can be read now)", twice: true}, {name: "an undeclared path the next declaration does not name either", before: withAccount, account: provisionerUID, path: "/etc/m/other.conf", after: withAccount}, {name: "an undeclared path the next declaration names", before: withAccount, account: provisionerUID, @@ -141,6 +146,9 @@ func TestAnUndeclaredPathIsRefused(t *testing.T) { return "", "", row.place } } + if row.unreadable { + e.Declared = func() (*declaration.Declaration, error) { return nil, errors.New("the agent restarts") } + } _ = e.Accepts(row.account) e.Write(context.Background(), row.account, Request{Path: row.path, Content: []byte("x")}) after := declared(t, row.after) -- 2.54.0 From 015304f4a3dcd197d22d9952e11a804a82c9093a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 23:09:00 +0100 Subject: [PATCH 60/73] Fold three tests into existing ones, drive the marker test by channels, keep the last view through set(nil) and drop it once the node is held Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-host/apply_context_test.go | 1 + cmd/mesh-host/marker_test.go | 16 +++++----- cmd/mesh-host/nodeagent.go | 31 +++++++++++++++++-- cmd/mesh-host/whatruns_test.go | 31 ++++++++++++++++--- .../selfcompose/compose_while_passing_test.go | 2 +- internal/selfcompose/composedstate_test.go | 1 + 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/cmd/mesh-host/apply_context_test.go b/cmd/mesh-host/apply_context_test.go index 2bb99be..a78a993 100644 --- a/cmd/mesh-host/apply_context_test.go +++ b/cmd/mesh-host/apply_context_test.go @@ -27,6 +27,7 @@ func TestAnApplyOutlivesTheLinksContext(t *testing.T) { // An apply is bounded all the same: a step that hangs (a docker call that never returns) cannot hold the node agent for // ever (the review of issue 1945). func TestAnApplyIsBounded(t *testing.T) { + t.Run("the marks of the haves never hold the pass", marksOfTheHavesNeverHoldThePass) ctx, done := applyContext(context.Background()) defer done() at, ok := ctx.Deadline() diff --git a/cmd/mesh-host/marker_test.go b/cmd/mesh-host/marker_test.go index 68c86e5..429014f 100644 --- a/cmd/mesh-host/marker_test.go +++ b/cmd/mesh-host/marker_test.go @@ -8,15 +8,16 @@ import ( // Issue 1965 (shanks 2026-11-05 22:22): the marks of the haves never hold the composition pass. A mark that stalls on the // bus is cut at markWithin, a mark set while one runs replaces the pending one of its name, and set itself never waits. -func TestMarksOfTheHavesNeverHoldThePass(t *testing.T) { - setMarkWithin(t, 30*time.Millisecond) +func marksOfTheHavesNeverHoldThePass(t *testing.T) { + // The bound is the test's own: the stalled mark is released by a channel, never by a real wait. + setMarkWithin(t, time.Hour) m := newMarker(context.Background()) + release := make(chan struct{}) var ran []string stalled := make(chan struct{}) - began := time.Now() - m.set("refused", func(ctx context.Context) { // a have write that stalls until its bound + m.set("refused", func(ctx context.Context) { // a have write that stalls until the test lets it go close(stalled) - <-ctx.Done() + <-release ran = append(ran, "refused-stalled") }) <-stalled @@ -24,9 +25,8 @@ func TestMarksOfTheHavesNeverHoldThePass(t *testing.T) { m.set("kept", func(context.Context) { ran = append(ran, "kept-old") }) m.set("kept", func(context.Context) { ran = append(ran, "kept-new") }) m.set("refused", func(context.Context) { ran = append(ran, "refused-new") }) - if took := time.Since(began); took > 20*time.Millisecond { - t.Errorf("setting marks took %v; it must not wait on the stalled one", took) - } + // All four sets above returned while the first mark still stalled: set never waits on it. + close(release) m.wait() want := []string{"refused-stalled", "kept-new", "refused-new"} if len(ran) != len(want) { diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index a1b4568..6cf6335 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -703,8 +703,12 @@ type agentNow struct { // read no haves yet, what runs is this and never the retired kept declaration (a bus link that drops restarts the // agent, and every fill in that window was refused; issue 924). last *runsView + // view reads an agent's view; viewOf unless a test stands in for an agent that holds the node. + view func(*agent.Agent) *runsView } +func viewOf(a *agent.Agent) *runsView { return &runsView{a.Everything(), a.HoldsNode()} } + // runsView is what an agent said runs, to be read after the agent stopped. type runsView struct { everything []compositions.Composed @@ -714,7 +718,11 @@ type runsView struct { func (c *agentNow) set(a *agent.Agent) { c.mu.Lock() if a == nil && c.ag != nil { - if v := (&runsView{c.ag.Everything(), c.ag.HoldsNode()}); len(v.everything) > 0 || v.holdsNode { + read := c.view + if read == nil { + read = viewOf + } + if v := read(c.ag); len(v.everything) > 0 || v.holdsNode { c.last = v } } @@ -722,6 +730,22 @@ func (c *agentNow) set(a *agent.Agent) { c.mu.Unlock() } +func (c *agentNow) viewFn() func(*agent.Agent) *runsView { + c.mu.Lock() + defer c.mu.Unlock() + if c.view == nil { + return viewOf + } + return c.view +} + +// holdsNode drops the kept view once the agent in hand holds the node: its own view is complete from then on. +func (c *agentNow) holdsNode() { + c.mu.Lock() + c.last = nil + c.mu.Unlock() +} + // lastView is the view the last agent held when it stopped, nil when none held any. func (c *agentNow) lastView() *runsView { c.mu.Lock() @@ -914,7 +938,10 @@ func runsFromAgent(current *agentNow) func(string, ed25519.PublicKey) (*declarat kept := func() (*declaration.Declaration, error) { return keptDeclaration(statePath, signer) } var view *runsView if ag := current.get(); ag != nil { - view = &runsView{ag.Everything(), ag.HoldsNode()} + view = current.viewFn()(ag) + if view.holdsNode { + current.holdsNode() + } } // An agent that has read no haves yet, or none: the last view held, never the retired kept declaration. if view == nil || (len(view.everything) == 0 && !view.holdsNode) { diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 8d0a787..50da892 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -63,6 +63,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) { // What the take act, the judgement of directories and the filling read is what runs, not the frozen kept declaration. func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { + t.Run("a fill while the agent restarts", aFillWhileTheAgentRestartsIsWrittenAfterwards) setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) { return &declaration.Declaration{Version: 1, Resources: nil, Provisioner: "from-agent"}, nil }) @@ -243,7 +244,7 @@ func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) { // Issue 924: the bus link drops, the agent restarts, and the filling is asked while it holds nothing yet. What runs is // then the last view the node held, never the retired kept declaration; and a fill refused for want of any is owed and // told once the agent holds the node again, so every file is written afterwards. -func TestAFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { +func aFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { keptRetired.Store(true) defer keptRetired.Store(false) node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Provisioner: "mesh-node-provisioner"}} @@ -251,7 +252,15 @@ func TestAFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { view := &runsView{everything: append(files, node), holdsNode: true} var placed []string - current := &agentNow{} + // The agent holds the node, then the link drops and it stops: set(nil) keeps its view. + holding := true + current := &agentNow{view: func(*agent.Agent) *runsView { + if holding { + return view + } + return &runsView{} + }} + current.set(agent.New(agent.Env{})) e := &filling.Engine{ Declared: func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) }, UIDOf: func(string) (uint32, error) { return 4242, nil }, @@ -269,16 +278,28 @@ func TestAFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) { t.Fatal(err) } - // The node never held a view (a cold start): refused, and owed. + // The next agent has read no haves and the previous one's view is gone (a cold start): refused, and owed. + cold := &agentNow{} + cold.set(agent.New(agent.Env{})) + cold.set(nil) + e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(cold)("/nowhere", nil) } if a := fill(); a.Failed == "" && a.Refused == "" { t.Fatalf("a fill with nothing read was accepted: %+v", a) } - // The agent stopped holding the node: the last view it held answers, and the fill is written. - current.last = view + // The link drops: set(nil) keeps the view the agent held, and the fill during the restart is written. + e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) } + current.set(nil) if told, err := e.Knock(socket); err != nil || told == "" { t.Fatalf("the refused fill was not owed and told: %q, %v", told, err) } if a := fill(); a.Failed != "" || a.Refused != "" || len(placed) != 1 { t.Fatalf("the fill during the restart was not written: %+v, placed %v", a, placed) } + // The new agent holds the node: its own view answers and the kept one is dropped. + next := agent.New(agent.Env{}) + current.set(next) + holding = true + if _, err := runsFromAgent(current)("/nowhere", nil); err != nil || current.lastView() != nil { + t.Fatalf("the kept view stays after the new agent holds the node: %v, %v", err, current.lastView()) + } } diff --git a/internal/selfcompose/compose_while_passing_test.go b/internal/selfcompose/compose_while_passing_test.go index ed56149..ea37027 100644 --- a/internal/selfcompose/compose_while_passing_test.go +++ b/internal/selfcompose/compose_while_passing_test.go @@ -21,7 +21,7 @@ import ( // Issue 1965 (shanks 2026-11-05 22:27: the verb compose gave no answer in 30 s while a pass waited on the bus): an ask // that comes while a pass runs is answered at once, says one more pass is queued and what the running one waits on, and // the queued pass is made when the running one ends. -func TestComposeAnsweredWhileAPassRuns(t *testing.T) { +func composeAnsweredWhileAPassRuns(t *testing.T) { b := newFakeBus() ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/selfcompose/composedstate_test.go b/internal/selfcompose/composedstate_test.go index a1a45f8..94a25d7 100644 --- a/internal/selfcompose/composedstate_test.go +++ b/internal/selfcompose/composedstate_test.go @@ -69,6 +69,7 @@ func (f *fakeComposed) written() []string { // the same build landing again writes nothing. And a module's keys are kept while among the five it wrote last or named // by a want, and deleted otherwise. func TestTheNodePublishesItsCompositions(t *testing.T) { + t.Run("the verb compose is answered while a pass runs", composeAnsweredWhileAPassRuns) b := newFakeBus() fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) ofp := meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64) -- 2.54.0 From 393f6902b36c6f7fb4fae30f15e44f905a88ce3b Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 00:51:22 +0100 Subject: [PATCH 61/73] Complete go.mod and go.sum for the externals each module builds with A check's tests run without network, so each module must carry every external module it builds with. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go.mod | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 295c246..e1a2feb 100644 --- a/go.mod +++ b/go.mod @@ -22,10 +22,6 @@ require ( git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 - github.com/nats-io/nats-server/v2 v2.11.17 - github.com/nats-io/nats.go v1.54.0 - golang.org/x/crypto v0.57.0 - golang.org/x/sys v0.48.0 ) require ( @@ -36,6 +32,17 @@ require ( git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 +) + +require ( + git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect +) + +require ( + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.54.0 + golang.org/x/crypto v0.57.0 + golang.org/x/sys v0.48.0 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect @@ -45,7 +52,3 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/time v0.16.0 // indirect ) - -require ( - git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect -) -- 2.54.0 From d064c41057af616a2071a135c6a28057d4d77c37 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 00:52:18 +0100 Subject: [PATCH 62/73] Take tidy's go.mod whole: mesh requirements at the zero version only, unused ones dropped Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go.mod | 54 +++++++++++++++++++++++++----------------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index e1a2feb..870a699 100644 --- a/go.mod +++ b/go.mod @@ -3,46 +3,37 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 -) - -require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 - git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 -) - -require ( - git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect -) - -require ( github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 +) + +require ( + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect @@ -52,3 +43,8 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/time v0.16.0 // indirect ) + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect +) -- 2.54.0 From dae2aa0be10ab024b76aaa0731f1ba6bf5379757 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 01:38:43 +0100 Subject: [PATCH 63/73] gofmt the files the group try listed Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/link/rotate.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/link/rotate.go b/internal/link/rotate.go index f9a1a78..23e271c 100644 --- a/internal/link/rotate.go +++ b/internal/link/rotate.go @@ -8,7 +8,6 @@ import ( "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" "sync" "time" - ) // Rotator is the engine's side of a node's account rotation (novox/hq ADR 0583 §3-§6, issue 774). The vault begins a rotation -- 2.54.0 From a397492503d2916a330230a031547c08d6a198b0 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 01:43:37 +0100 Subject: [PATCH 64/73] The link tests are given the seat list as the node-engine gives it from the bus, so the node seat has its verbs to serve (issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/link/seats_for_tests_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 internal/link/seats_for_tests_test.go diff --git a/internal/link/seats_for_tests_test.go b/internal/link/seats_for_tests_test.go new file mode 100644 index 0000000..7994cc2 --- /dev/null +++ b/internal/link/seats_for_tests_test.go @@ -0,0 +1,10 @@ +package link + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus +// (novox/hq ADR 0635 §1); catalogue compiles none. +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } -- 2.54.0 From 3b700886f238eeb75ae87918b6599cab64e5eb02 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 10:05:27 +0100 Subject: [PATCH 65/73] The composer reads a case's verdict as the running checker hands it, version 0 and no judge (issue 1966) A case's verdicts are the checker's account of the indexer's modules values, not the indexer's kept values: the trunk's checker writes them with version 0 and no judge, so the kept value's strict reader refused every one and the group try failed composition on all four nodes ("the verdict of f1-circuits is not one: ... version 0"). The composer now takes version 0 (or none) and keys.Version, its judge optional, and still refuses a later version, a value that does not decode and an unknown state. The bus reader of kept verdicts stays strict. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/composer/composer.go | 26 +++++++++++++++++++++++++- internal/composer/composer_test.go | 16 ++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/composer/composer.go b/internal/composer/composer.go index efd4688..2d97b9a 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -13,6 +13,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "slices" "sort" @@ -20,6 +21,7 @@ import ( sdk "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/sdkversion" "github.com/novox/mesh-host/internal/compose" @@ -105,7 +107,7 @@ func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[st if err != nil { read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err) } else if raw, ok := verdicts[name]; ok { - v, verr := meshcatalogclient.DecodeVerdict(raw) + v, verr := caseVerdict(raw) if verr != nil { return nil, fmt.Errorf("composer: the verdict of %s is not one: %w", name, verr) } @@ -128,6 +130,28 @@ func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[st return out, nil } +// caseVerdict reads a verdict a case carries. It is not the indexer's kept value but the checker's account of it, made +// from the indexer's modules value: a checker of the trunk writes it with version 0 and no judge, one of the registry +// milestone with no version and no judge, and the composer of a group's head is asked by the checker that runs. So it is +// read in the kept value's form at version 0 (or none) and at keys.Version, its judge optional; a later version, a value +// that does not decode and a state no reader knows are refused, never taken as accepted. +func caseVerdict(raw json.RawMessage) (meshcatalog.VerdictsByDigestValue, error) { + var v meshcatalog.VerdictsByDigestValue + if err := json.Unmarshal(raw, &v); err != nil { + return v, fmt.Errorf("the value does not fit a verdict's shape: %w", err) + } + if v.V != 0 && v.V != keys.Version { + return v, fmt.Errorf("%w: the value is version %d, this reader reads 0 and %d", keys.ErrUnknownVersion, v.V, keys.Version) + } + switch v.State { + case meshcatalogclient.VerdictAccepted, meshcatalogclient.VerdictRefused, meshcatalogclient.VerdictManifestWaits: + default: + return v, fmt.Errorf("the state %q is none of %s, %s and %s", v.State, meshcatalogclient.VerdictAccepted, + meshcatalogclient.VerdictRefused, meshcatalogclient.VerdictManifestWaits) + } + return v, nil +} + func runCase(r Request, c Case, depth string) (CaseVerdict, error) { cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}} shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts) diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index bd2e9d6..75cefeb 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -3,6 +3,7 @@ package composer import ( "context" "encoding/json" + "errors" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -15,6 +16,7 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/keys" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/frombus/frombustest" @@ -262,4 +264,18 @@ func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { !strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") { t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got) } + // The verdict as the trunk's checker hands it, version 0 and no judge (group try 1793952957648659842 failed on all + // four nodes: "the verdict of f1-circuits is not one: ... the value is version 0"), holds the module too; a later + // version is refused. + r.Cases[0].Verdicts = map[string]json.RawMessage{"xterm": json.RawMessage(`{"v":0,"module":"xterm","state":"refused","problems":["falls back to a key not listed"],"judge":"","at":"0001-01-01T00:00:00Z"}`)} + if v, err = Run(r); err != nil { + t.Fatalf("a version-0 verdict of the trunk's checker: %v", err) + } + if got := v.Cases[0].Manifests["xterm"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of xterm: falls back") { + t.Fatalf("xterm refused by a version-0 verdict: %+v, want refused", got) + } + r.Cases[0].Verdicts["xterm"] = json.RawMessage(`{"v":2,"module":"xterm","state":"refused","problems":["p"]}`) + if _, err = Run(r); !errors.Is(err, keys.ErrUnknownVersion) { + t.Fatalf("a version-2 verdict: %v, want keys.ErrUnknownVersion", err) + } } -- 2.54.0 From eec35a7c0c22a07086acc62c8390cfa181381da5 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 12:01:00 +0100 Subject: [PATCH 66/73] The sandbox composer composes against the seat list its request carries, and judges nothing without one (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group try of 2026-11-06 10:07 failed its composition on all four machines because mesh-compose, the node-engine's composer a check builds at the head, loaded no seat list once the compiled one was deleted (ADR 0635 §1): the uplink's holder no longer stepped back from the resolver file, and no holder of the registry seats was known. It now takes the request's seats over the foundation's bootstrapped ones, as the node-engine takes the bus's; without any it says each machine not judged (ADR 0636 §1, ADR 0593 §9), never a refusal or pass made without them. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- cmd/mesh-compose/main.go | 48 ++++++++++++++++++++++++++++++++++- cmd/mesh-compose/main_test.go | 46 ++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/cmd/mesh-compose/main.go b/cmd/mesh-compose/main.go index 22a5753..bd30708 100644 --- a/cmd/mesh-compose/main.go +++ b/cmd/mesh-compose/main.go @@ -23,11 +23,17 @@ import ( "flag" "fmt" "io" + "maps" "os" "path/filepath" + "slices" "sort" "strings" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "github.com/novox/mesh-host/internal/composer" ) @@ -105,7 +111,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if *declarations { req.Declarations = true } - v, err := composer.Run(req) + v, err := runWithSeats(req) if err != nil { fmt.Fprintln(stderr, "mesh-compose:", err) return 1 @@ -118,6 +124,46 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 } +// NoSeatList is what a machine reads, not judged, when the request carries no seat list (novox/hq ADR 0636 §1). +const NoSeatList = "the composer was handed no seat list, so it composes nothing (a reader of the seat list acts on nothing " + + "new until it has read the list, novox/hq ADR 0636 §1): the asker gives the seat definitions it judges against in the request's seats" + +// runWithSeats composes req against the seat list the request carries (Request.Seats: the definitions the asker's seat list +// reader holds, the members' heads' laid over them in a check, ADR 0636 §4), over the foundation's bootstrapped ones +// (seatlist.Bootstrapped), as the node-engine composes against the list it read from the bus (ADR 0635 §1). The command +// compiles no seat list: without one in the request it reads every manifest and judges no machine, each said not judged +// with NoSeatList (ADR 0593 §9), never a refusal or a pass made without the seats' paths, deliveries and holders. +func runWithSeats(req composer.Request) (composer.Verdict, error) { + if len(req.Seats) == 0 { + machines := req.Machines + req.Machines = nil + v, err := composer.Run(req) + if err != nil || len(machines) == 0 { + return v, err + } + for i := range v.Cases { + v.Cases[i].Machines = map[string]composer.Machine{} + for _, node := range machines { + v.Cases[i].Machines[node] = composer.Machine{NotJudged: NoSeatList} + } + } + return v, nil + } + defs := map[string]seatdef.Definition{} + for _, d := range seatlist.Bootstrapped() { + defs[d.Seat] = d + } + for _, raw := range req.Seats { + d, err := seatdef.Parse(raw) + if err != nil { + return composer.Verdict{}, fmt.Errorf("a seat definition of the request: %w", err) + } + defs[d.Seat] = d + } + catalogue.UseSeats(catalogue.SeatsFromDefinitions(slices.Collect(maps.Values(defs)))) + return composer.Run(req) +} + // readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of // modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said. func readCatalogue(dir string) (map[string]composer.Manifest, error) { diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index 0b69063..ae0fcf4 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -17,6 +17,7 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" "github.com/novox/mesh-host/internal/beside" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/composer" @@ -49,11 +50,25 @@ func meshOf() compose.Input { func request(t *testing.T, depth string) composer.Request { t.Helper() r := composer.Request{V: composer.Version, Depth: depth, HoldingsFromClaims: true, - Machines: []string{"anchor", "laptop", "ace"}, - Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} + Machines: []string{"anchor", "laptop", "ace"}, Seats: seats(t), + Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} return withInput(t, r, meshOf()) } +// seats is the seat list a request carries, as the checker hands the one its reader holds (ADR 0635 §1, ADR 0636 §4). +func seats(t *testing.T) []json.RawMessage { + t.Helper() + var out []json.RawMessage + for _, d := range seatlisttest.Definitions() { + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + out = append(out, raw) + } + return out +} + // withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON. func withInput(t *testing.T, r composer.Request, in compose.Input) composer.Request { t.Helper() @@ -124,6 +139,31 @@ func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { t.Errorf("%s does not compose and says no reason", name) } } + + // Issue 1966, the group try of 2026-11-06 10:07: the composer judges against the seat list its request carries, the + // node-engine's from the bus (ADR 0635 §1). The uplink's holder steps back from the resolver file for the resolver's + // holder by the seat's definition; without the list nothing is judged (ADR 0636 §1), never "both declare the path". + resolverMachine := func(withSeats bool) composer.Machine { + r := composer.Request{V: composer.Version, Depth: composer.DepthCompose, HoldingsFromClaims: true, + Machines: []string{"laptop"}, Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} + if withSeats { + r.Seats = seats(t) + } + r = withInput(t, r, compose.Input{Nodes: []compose.Node{{Name: "laptop", + Assigned: []string{"networkmanager", "systemd-resolved", "pacman", "systemd", "dnsmasq"}, + Capabilities: map[string]bool{"file-references": true, "privileged": true, "service-manager": true, "package-manager": true, "uplink-networkmanager": true}}}}) + code, v, stderr := runJSON(t, nil, encode(t, r)) + if code != 0 || len(v.Cases) != 1 { + t.Fatalf("exit %d: %s", code, stderr) + } + return v.Cases[0].Machines["laptop"] + } + if m := resolverMachine(true); strings.Contains(m.Reason+strings.Join(m.Problems, " "), "both declare") || m.NotJudged != "" { + t.Errorf("with the seat list, the uplink's and the resolver's holders on one machine: %+v, want the uplink's stepping back", m) + } + if m := resolverMachine(false); m.Composes || m.NotJudged != NoSeatList || m.Reason != "" { + t.Errorf("without a seat list: %+v, want not judged, saying why", m) + } } // 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for @@ -196,7 +236,7 @@ func TestACatalogueCheckoutIsComposedForANode(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { t.Fatal(err) } - in := encode(t, withInput(t, composer.Request{V: composer.Version}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})) + in := encode(t, withInput(t, composer.Request{V: composer.Version, Seats: seats(t)}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})) code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in) if code != 0 { t.Fatalf("exit %d: %s", code, stderr) -- 2.54.0 From e96a7ada50057e73417abb5a0e356f151d17fbe5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 7 Nov 2026 02:26:14 +0100 Subject: [PATCH 67/73] =?UTF-8?q?The=20node-engine=20keeps=20no=20copy=20o?= =?UTF-8?q?f=20a=20seat's=20verbs:=20the=20seat=20list=20comes=20from=20th?= =?UTF-8?q?e=20bus=20(issue=201921,=20ADR=200635=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight lists of seat verbs (checker, enrolment, artifact sweep, delivery, node-resolver, node-nfs-server, node-mounts, mesh-config's input) and their schema helpers were read by nothing but one test, and drifted from their seats (B0's verb input never reached the checker's copy). A guard row in the seats test finds any list of the catalogue's verbs written in the node-engine's code. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../compose/resolution/artifact_sweep_seat.go | 43 --------- internal/compose/resolution/checker_seat.go | 33 ------- internal/compose/resolution/delivery_seat.go | 75 --------------- .../compose/resolution/delivery_seat_test.go | 30 ------ internal/compose/resolution/enrolment_seat.go | 67 ------------- internal/compose/resolution/node_resolver.go | 30 ------ internal/compose/resolution/seats.go | 15 --- internal/compose/resolution/seats_test.go | 94 +++++++++++++++++++ internal/compose/resolution/shares.go | 78 --------------- internal/compose/resolution/verbs.go | 59 ------------ 10 files changed, 94 insertions(+), 430 deletions(-) delete mode 100644 internal/compose/resolution/artifact_sweep_seat.go delete mode 100644 internal/compose/resolution/checker_seat.go delete mode 100644 internal/compose/resolution/delivery_seat.go delete mode 100644 internal/compose/resolution/delivery_seat_test.go delete mode 100644 internal/compose/resolution/enrolment_seat.go delete mode 100644 internal/compose/resolution/shares.go delete mode 100644 internal/compose/resolution/verbs.go diff --git a/internal/compose/resolution/artifact_sweep_seat.go b/internal/compose/resolution/artifact_sweep_seat.go deleted file mode 100644 index 98888e7..0000000 --- a/internal/compose/resolution/artifact_sweep_seat.go +++ /dev/null @@ -1,43 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks. -func artifactSweepVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " + - "repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " + - "newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " + - "unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", - Input: schema(map[string]string{ - "repository": "only this repository's references, as / (the counts stay whole)", - "collected": "\"true\": list the collected references too (counted always)", - }, nil, "collected")}, - {Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " + - "held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " + - "Reads only.", - Input: listed(schema(map[string]string{ - "references": "the artifacts' references, artifact-store:////blobs/sha256:: a list, " + - "or one text separated by commas", - }, []string{"references"}), "references")}, - {Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " + - "dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " + - "then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " + - "names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " + - "seconds; what is left is said. Never touches a digest the mesh did not make.", - Input: schema(map[string]string{ - "confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done", - "why": "with confirm: why, required and recorded", - "most": "let go of at most this many (500 by default, at most 5000)", - }, nil, "confirm")}, - {Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " + - "it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " + - "it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", - Input: schema(map[string]string{}, nil)}, - {Name: "images", Description: "Every container image one machine's want and have name, each with the resources " + - "naming it: what the machine keeps when it prunes its images. Reads only.", - Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, - } -} diff --git a/internal/compose/resolution/checker_seat.go b/internal/compose/resolution/checker_seat.go deleted file mode 100644 index 337ffa9..0000000 --- a/internal/compose/resolution/checker_seat.go +++ /dev/null @@ -1,33 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them -// everywhere. -func checkerVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + - "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " + - "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " + - "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " + - "4 wait or run at once, mesh-wide.", - Input: schema(map[string]string{"repository": "/", - "head": "the full commit to try, 40 or 64 hex digits", - "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", - "tests": "a go test -run pattern: only those tests run, and nothing is kept", - "level": "unit or integration: only this level; both when absent"}, - []string{"repository", "head"}), - Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, - {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " + - "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " + - "it was merged onto, the time it took and what failed first. Read-only.", - Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})}, - {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " + - "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " + - "id. A try that ended already is refused.", - Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"}, - []string{"id", "why"})}, - } -} diff --git a/internal/compose/resolution/delivery_seat.go b/internal/compose/resolution/delivery_seat.go deleted file mode 100644 index 851b592..0000000 --- a/internal/compose/resolution/delivery_seat.go +++ /dev/null @@ -1,75 +0,0 @@ -package resolution - -import ( - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat. -func deliveryRemovedInR6(name string) mcat.Verb { - return mcat.Verb{Name: name, Description: meshdeliveryclient.RemovedPrefix + meshdeliveryclient.RemovedVerbs()[name], Input: schema(nil, nil), Optional: true} -} - -// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2 -// on a merge (ADR 0539 clause 17). -func deliveryVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " + - "each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " + - "by state, repository or group.", - Input: schema(map[string]string{"state": "one state, e.g. delivering or held", - "repository": "owner/repository", "group": "a group's id (its branch name)", - "all": "\"true\": the final ones of the last thirty days too"}, nil, "all")}, - deliveryRemovedInR6("times"), - {Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " + - "machine receives, what is not an ordinary send), every transition with when and why, the machine " + - "steps of its walk, its group and its order.", - Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})}, - {Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " + - "(declared, built by, version skew, engine before controller, by name), its composed check and its " + - "state, derived from its members.", - Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")}, - deliveryRemovedInR6("what-if"), - {Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " + - "of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " + - "mesh/delivery, …) with its state, " + - "description and when it was set; the merge check's full verdict as the controller said it — each " + - "layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " + - "protection would let it merge, every required status being success.", - Input: schema(map[string]string{"repository": "owner/repository", - "number": "a pull request's number: its head is read", - "commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}), - // Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere. - Optional: true}, - {Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " + - "each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", - Input: schema(map[string]string{}, nil)}, - {Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " + - "lets healer H2 take for it, or none.", - Input: schema(map[string]string{}, nil)}, - {Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " + - "controller is asked for its check. With why.", - Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"}, - []string{"id", "why"})}, - deliveryRemovedInR6("release"), - deliveryRemovedInR6("stop"), - {Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " + - "past its bound. Refused for any other.", - Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"}, - []string{"id", "why"})}, - deliveryRemovedInR6("retire-history"), - {Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " + - "trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " + - "The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " + - "is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " + - "reason, naming why.", - Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"}, - "properties": map[string]any{ - "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1, - "description": "the held deliveries' ids (owner/repository@commit)"}, - "why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"}, - }}, - // Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere. - Optional: true}, - } -} diff --git a/internal/compose/resolution/delivery_seat_test.go b/internal/compose/resolution/delivery_seat_test.go deleted file mode 100644 index 5088f6d..0000000 --- a/internal/compose/resolution/delivery_seat_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package resolution - -import ( - "testing" - - "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" -) - -// Each verb release 6 removed from the delivery seat is a row refusing it by name with the replacement the seat's client -// gives (the list is the contract's, not the SDK's: novox/hq ADR 0634 §2), and the rows are exactly those verbs. -func TestTheDeliverySeatRefusesTheRemovedVerbsByNameWithTheClientsReplacement(t *testing.T) { - removed := meshdeliveryclient.RemovedVerbs() - rows := 0 - for _, v := range deliveryVerbs() { - want, isRemoved := removed[v.Name] - if !isRemoved { - continue - } - rows++ - if v.Description != meshdeliveryclient.RemovedPrefix+want || !v.Optional { - t.Errorf("%s = %q (optional %v), want %q", v.Name, v.Description, v.Optional, meshdeliveryclient.RemovedPrefix+want) - } - } - if rows != len(removed) { - t.Errorf("%d rows refuse a removed verb, the client names %d", rows, len(removed)) - } - if len(removed) != 5 { - t.Errorf("the removed verbs are %v", removed) - } -} diff --git a/internal/compose/resolution/enrolment_seat.go b/internal/compose/resolution/enrolment_seat.go deleted file mode 100644 index 68ffb9f..0000000 --- a/internal/compose/resolution/enrolment_seat.go +++ /dev/null @@ -1,67 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// enrolmentDecision is the record that made the seat. -const enrolmentDecision = "novox/hq ADR 0587" - -// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional: -// the seat is new with its holder, so no holder of before serves fewer. -func enrolmentVerbs() []mcat.Verb { - str := func(description string) map[string]any { - return map[string]any{"type": "string", "description": description} - } - return []mcat.Verb{ - {Name: mcat.EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " + - "refused at once, naming every finding, for a name that is not a node name or that a node record, an assignments " + - "key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " + - "reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " + - "warrant, adds the node to the assignments with its tunnel key, asks the vault for the token and says enrolment-issued. " + - "The token itself is read only through tokens.", - Input: schema(map[string]string{ - "node": "the new machine's name in the mesh", - "why": "why it joins, shown to the operator with the ask", - "expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", - // The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node - // enters the assignments with it and the hub peers it before redeem. - "overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", - }, []string{"node", "why", "overlay_key"})}, - {Name: mcat.EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol. " + - "alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " + - "vault for the node's account and certificate, writes the node into the assignments, and answers the account sealed " + - "to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " + - "it finishes what is not done and answers the same; with other keys it is refused.", - Input: map[string]any{"type": "object", "additionalProperties": false, - "required": []string{"node", "public_key", "sealing_key", "proof"}, - "properties": map[string]any{ - "node": str("the node the token was issued for"), - "public_key": str("the identity key's public half, base64"), - "overlay_key": str("the private network key's public half"), - "sealing_key": str("the sealing key's public half, which the account is sealed to"), - "serving_key": str("the serving key's public half, which the certificate binds"), - "profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"}, - "tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"}, - "proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"), - }}}, - {Name: mcat.EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " + - "node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " + - "enrolment.", - Input: schema(map[string]string{}, nil)}, - {Name: mcat.EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " + - "an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " + - "it, records the act as a hand act and says enrolment-revoked.", - Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"}, - []string{"node", "why"})}, - {Name: mcat.EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " + - "the bucket's fill against its cap. For a node at live, its " + - "token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " + - "history, the ended enrolments from the seat's events, as far back as the events are kept.", - Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"}, - nil, "history")}, - {Name: mcat.EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " + - "and nothing more.", - Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})}, - } -} diff --git a/internal/compose/resolution/node_resolver.go b/internal/compose/resolution/node_resolver.go index ef796db..2016026 100644 --- a/internal/compose/resolution/node_resolver.go +++ b/internal/compose/resolution/node_resolver.go @@ -6,36 +6,6 @@ import ( mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed -// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to -// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the -// operator's way to read and correct the same. -func resolverVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " + - "which answer every name not routed elsewhere, and each link given servers of its own with the " + - "domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " + - "wrote it as far as the file says, whether a module took it, and when the resolver's own file was " + - "put back.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}}, - {Name: "route", Description: "Send these domains, and every name under them, to these servers over " + - "this link — and only them: the link is never the machine's default route for names, and the " + - "mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " + - "takes its route with it.", - Input: schema(map[string]string{ - "link": "the network link the servers are reached over, by name (a VPN's tunnel interface)", - "domains": "the domains to route there, separated by spaces or commas", - "servers": "the servers' addresses, separated by spaces or commas", - }, []string{"link", "domains", "servers"}), - Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}}, - {Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " + - "resolvers. Nothing changes when the link has none.", - Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}), - Replaces: []string{"resolvectl revert"}}, - } -} - // holdsSeat says whether a module claims a seat, by its current name. func holdsSeat(m mcat.Manifest, seat string) bool { for _, c := range m.Claims { diff --git a/internal/compose/resolution/seats.go b/internal/compose/resolution/seats.go index 8813719..b29c3d8 100644 --- a/internal/compose/resolution/seats.go +++ b/internal/compose/resolution/seats.go @@ -4,21 +4,6 @@ import ( mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its -// text), and a limit that is a number; every other argument a string. -func configInput() map[string]any { - in := schema(map[string]string{"action": "list (when not given), set, clear or history", - "key": "the config key", "value": "for set: the value, of the key's type", - "module": "a module: its own value, with node", "node": "a node", - "why": "for set and clear: why", "by": "who gave the value at the controller's terminal", - "limit": "for history: at most this many, newest"}, nil) - props := in["properties"].(map[string]any) - props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " + - "a boolean, or for a trusted key an object or a list"} - props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"} - return in -} - func providesAt(m mcat.Manifest, provision, scope string) bool { for _, o := range m.Provides { if o.Name == provision && o.At() == scope { diff --git a/internal/compose/resolution/seats_test.go b/internal/compose/resolution/seats_test.go index 4a72341..73a0c89 100644 --- a/internal/compose/resolution/seats_test.go +++ b/internal/compose/resolution/seats_test.go @@ -1,6 +1,12 @@ package resolution import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" "strings" "testing" @@ -70,4 +76,92 @@ func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) } + // A seat's verbs are its definition's, read from the bus (novox/hq ADR 0635 §1): the node-engine keeps no copy of + // them, so no copy can drift from the seat as the seven it kept did (issue 1921). A copy is a list of the catalogue's + // verbs written in the code; the guard proves it finds one, then finds none in the node-engine. + t.Run("no seat's verbs are copied into the node-engine", func(t *testing.T) { + copied := `package x + +import mcat "git.novox.be/novox/mesh-sdk/go/catalogue" + +func someVerbs() []mcat.Verb { return []mcat.Verb{{Name: "routes"}} } +` + if got := verbCopies(t, "copied.go", copied); len(got) == 0 { + t.Fatal("the guard finds no copy in a file that holds one, so it proves nothing") + } + var found []string + root := filepath.Join("..", "..", "..") + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && (d.Name() == "testdata" || strings.HasPrefix(d.Name(), ".")) && p != root { + return filepath.SkipDir + } + if d.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + found = append(found, verbCopies(t, p, nil)...) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(found) > 0 { + t.Errorf("the node-engine copies a seat's verbs instead of reading the seat list from the bus: %v", found) + } + }) +} + +// verbCopies is where one Go file writes the catalogue's verbs itself: a function answering a list of them, or a verb +// written as a literal. +func verbCopies(t *testing.T, name string, src any) []string { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, name, src, parser.SkipObjectResolution) + if err != nil { + t.Fatal(err) + } + alias := "" + for _, imp := range f.Imports { + if p, _ := strconv.Unquote(imp.Path.Value); p == "git.novox.be/novox/mesh-sdk/go/catalogue" { + alias = "catalogue" + if imp.Name != nil { + alias = imp.Name.Name + } + } + } + if alias == "" { + return nil + } + isVerb := func(e ast.Expr) bool { + if a, ok := e.(*ast.ArrayType); ok { + e = a.Elt + } + sel, ok := e.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Verb" { + return false + } + x, ok := sel.X.(*ast.Ident) + return ok && x.Name == alias + } + var out []string + ast.Inspect(f, func(n ast.Node) bool { + switch n := n.(type) { + case *ast.FuncDecl: + if n.Type.Results != nil { + for _, r := range n.Type.Results.List { + if isVerb(r.Type) { + out = append(out, fset.Position(n.Pos()).String()+" "+n.Name.Name) + } + } + } + case *ast.CompositeLit: + if n.Type != nil && isVerb(n.Type) { + out = append(out, fset.Position(n.Pos()).String()) + } + } + return true + }) + return out } diff --git a/internal/compose/resolution/shares.go b/internal/compose/resolution/shares.go deleted file mode 100644 index 7616c19..0000000 --- a/internal/compose/resolution/shares.go +++ /dev/null @@ -1,78 +0,0 @@ -package resolution - -import ( - mcat "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263). -func nfsServerVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " + - "read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " + - "node's private address and access (only the nodes the server grants it to and that ask for it), " + - "what is granted and not asked for or asked for and not granted, and whether the kernel holds it " + - "now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " + - "/etc/exports.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}}, - {Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " + - "knows its clients.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}}, - {Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " + - "NFS service is up; with an address, also whether the share is exported to that address: a node's " + - "own private address, when the server grants it the share and the node asks for it. What a machine " + - "mounting the share asks before it mounts.", - Input: schema(map[string]string{ - "share": "the share, by its name", - "address": "a node's private address, to ask whether the share is exported to it (optional)", - }, []string{"share"}), - Replaces: []string{"showmount -e"}}, - {Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " + - "-ra) and answer the shares as it then holds them. The module's own process writes its export " + - "file; this is for after a change made outside it. Changes no shared path.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"exportfs -ra"}}, - {Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " + - "property for a path the mesh's own export now serves — only when that export is live. Without " + - "confirm, says what it would do and changes nothing.", - Input: schema(map[string]string{ - "path": "the shared path whose hand-made export is taken over", - "confirm": "\"true\": change it (needs why); anything else is a dry run", - "why": "why, for the record", - }, []string{"path"}, "confirm"), - Replaces: []string{"zfs set sharenfs=off"}}, - } -} - -// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263). -func mountsVerbs() []mcat.Verb { - return []mcat.Verb{ - {Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " + - "automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " + - "and which settings asked for it. A password in a mount's options is never shown.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}}, - {Name: "test", Description: "Whether one share's or occasional source's server answers from this " + - "machine now, without touching its mount point.", - Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), - Replaces: []string{"showmount -e", "ping"}}, - {Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " + - "access.", - Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), - Replaces: []string{"mount"}}, - {Name: "unmount", Description: "Release one share or occasional source now; its automount stays " + - "armed, so the next access mounts it again.", - Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), - Replaces: []string{"umount"}}, - {Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " + - "line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " + - "the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", - Input: schema(map[string]string{ - "mountpoint": "the mount point whose fstab line is taken over", - "confirm": "\"true\": change it (needs why); anything else is a dry run", - "why": "why, for the record", - }, []string{"mountpoint"}, "confirm"), - Replaces: []string{"sed -i /etc/fstab"}}, - } -} diff --git a/internal/compose/resolution/verbs.go b/internal/compose/resolution/verbs.go deleted file mode 100644 index a57913e..0000000 --- a/internal/compose/resolution/verbs.go +++ /dev/null @@ -1,59 +0,0 @@ -package resolution - -import ( - "maps" - "slices" -) - -// schema is a JSON schema for an object of string properties, which is every argument the verbs -// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The -// switches are the properties that are "true" or "false" and nothing else, said in the schema as an -// enum so a caller sees it and the verb can refuse any other word rather than read it as false. -// -// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not -// name is refused when the verb is called, never passed over. -// withObject adds to a verb's input s the property name, an object of the string fields given, each required. -func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any { - inner := schema(fields, slices.Sorted(maps.Keys(fields))) - inner["description"], inner["additionalProperties"] = description, false - s["properties"].(map[string]any)[name] = inner - return s -} - -func schema(properties map[string]string, required []string, switches ...string) map[string]any { - isSwitch := map[string]bool{} - for _, s := range switches { - if _, declared := properties[s]; !declared { - panic("a switch that is not a property: " + s) - } - isSwitch[s] = true - } - props := map[string]any{} - for name, description := range properties { - p := map[string]any{"type": "string", "description": description} - if isSwitch[name] { - p["enum"] = []string{"true", "false"} - } - props[name] = p - } - out := map[string]any{"type": "object", "properties": props} - if len(required) > 0 { - out["required"] = required - } - return out -} - -// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as -// any argument, one text separated by commas). -func listed(in map[string]any, names ...string) map[string]any { - props, _ := in["properties"].(map[string]any) - for _, n := range names { - p, _ := props[n].(map[string]any) - if p == nil { - panic("a list that is not a property: " + n) - } - props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": p["description"]} - } - return in -} -- 2.54.0 From c114b786e89e163b985860ee1e9f3da1ee58e061 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 7 Nov 2026 02:33:21 +0100 Subject: [PATCH 68/73] The input a node answers a check with says what holds each held module and carries its build (issue 1921, milestone 47/G H3) A module the running indexer refused stood in the input as its stub with no build, so a check's composer that judges the change's manifest itself had nothing to compose it at. Each held module now says whether its verdict or the decoder holds it; for a check's answer the node also takes in the build it would compose it at, when the builder holds it done, beside the stub and never into what the node composes. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/compose/frombus/answer_rows_test.go | 20 ++++++++++ internal/compose/frombus/frombus.go | 41 ++++++++++++++++---- internal/compose/input.go | 25 ++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 6251e35..15c33bc 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -218,6 +218,26 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.Fp2, err, read.ComposedHow, raw) } + // Issue 1921: held by its manifest's verdict, spiegel is still held in what the node composes, and the input a check + // is answered with says what holds it and carries the build the node would compose it at, taken in while done; a + // node's own read takes in no build of it. + sp.Verdict, sp.Problems = meshcatalogclient.VerdictRefused, []string{"p"} + bb.modules["spiegel"] = sp + for _, done := range []bool{true, false} { + if !done { + bb.builds["spiegel "+sp.Fp2].State = builds.BuildBuilding + } + read, err = ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1"}, map[string]string{"lemurs": fp("e"), "spiegel": sp.Fp2}) + hold := read.Holds["spiegel"] + raw, _ = json.Marshal(hold.Built) + if err != nil || !read.Catalogue["spiegel"].Stubbed || hold.By != compose.HeldByVerdict || hold.Build != sp.Fp2 || + strings.Contains(string(raw), strings.Repeat("7", 64)) != done || (hold.Built == nil) == done { + t.Errorf("spiegel refused, its build done %v: %v, hold %+v (built %s)", done, err, hold, raw) + } + } + if own, err := Input(ctx, bb, Mesh{}, Scope{Node: "n1"}); err != nil || own.Holds["spiegel"] != (compose.Hold{By: compose.HeldByVerdict}) { + t.Errorf("a node's own read of spiegel refused: %v, hold %+v", err, own.Holds["spiegel"]) + } } // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index f83a051..7aabec4 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -167,6 +167,10 @@ type Scope struct { // Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool // runner, the core's credentials; ADR 0601 §4), so the node says it. Nil: said nowhere. Kept func(module, words string) + // takeInHeld, set for the input a node answers a check with (ComposingInput), takes in the build of each module placed + // on the node that its manifest's verdict or its decoder holds, into compose.Input.Holds, never into what the node composes: the + // check's composer, judging the change's manifest itself, composes it at that build (issue 1921). + takeInHeld bool } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -214,21 +218,23 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er // taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict // from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a // manifest not read past a field it set aside holds its name alone and is held the same. -func taken(name string, raw []byte, m meshcatalog.ModulesByModuleValue) (man catalogue.Manifest, why string) { +// +// by is what holds it (compose.HeldByVerdict or compose.HeldByDecoder), empty when nothing does. +func taken(name string, raw []byte, m meshcatalog.ModulesByModuleValue) (man catalogue.Manifest, why, by string) { if v := meshcatalogclient.ModuleVerdict(name, m); v != nil { if refused, _ := meshcatalogclient.TakeVerdict(v); refused { - return unusable(name, raw), meshcatalogclient.VerdictWhy(*v) + return unusable(name, raw), meshcatalogclient.VerdictWhy(*v), compose.HeldByVerdict } } man, err := catalogue.Decode(raw) switch { case err != nil: - return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err) + return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err), compose.HeldByDecoder case man.ReadNoFurther(): return unusable(name, raw), fmt.Sprintf("the manifest of %s could not be read past a field this reader does not "+ - "know: %s", name, man.SetAside()) + "know: %s", name, man.SetAside()), compose.HeldByDecoder } - return man, "" + return man, "", "" } // input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose @@ -374,9 +380,13 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { // follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused // manifest silenced disk-load for 45 hours). It stands in as its stub, so what it provides and requires still // shapes the rest; it is held on the composing node where it is placed there, and elsewhere only its offer is read. - man, why := taken(name, raw, m) + man, why, by := taken(name, raw, m) if why != "" { named(&in.Refused, name, why) + if in.Holds == nil { + in.Holds = map[string]compose.Hold{} + } + in.Holds[name] = compose.Hold{By: by} if ownPlaced[name] { b.ownRefused = append(b.ownRefused, [2]string{name, why}) } @@ -600,6 +610,7 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s // credential: the sandbox composes no pair value. func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (compose.Input, error) { held := map[string]string{} + s.takeInHeld = true in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps) if err != nil { return compose.Input{}, err @@ -909,7 +920,23 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m continue } if _, refused := in.Refused[pm.Module]; refused { - continue // held as its stub: no build of a refused manifest is taken in (ADR 0578 §3) + // Held as its stub: no build of a refused manifest is composed (ADR 0578 §3). For a check, the build the node + // would compose it at is said beside it, and taken in when done, for the check's composer alone. + if hold, said := in.Holds[pm.Module]; s.takeInHeld && said { + hold.Build = m.Fp2 + if w := wanted[pm.Module]; w != "" { + hold.Build = w + } + // Its stub names no build, so the builder is asked: a build not done, or not taken in, is awaited. + if b, err := build(ctx, pm.Module, hold.Build); err == nil && b != nil && b.State == builds.BuildDone { + if resolved, err := built(pm.Module, hold.Build, b); err == nil { + hold.Built = &resolved + } + } + in.Holds = maps.Clone(in.Holds) + in.Holds[pm.Module] = hold + } + continue } if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) { continue // nothing is built of it: its manifest is what it composes from diff --git a/internal/compose/input.go b/internal/compose/input.go index e1eaa9b..74c7c8d 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -114,11 +114,36 @@ type Input struct { // (compose.Stub), so it is held: nothing of its own is composed, while what it provides and requires still shapes the // rest. A module not named is accepted, a missing verdict included. Refused map[string]string `json:",omitempty"` + // Holds is, by module Refused names, what holds it and the build its node would compose it at (issue 1921): a check's + // composer that judges the change's manifest itself (novox/hq ADR 0578 §2) composes a module refused by the running + // indexer alone from that build, and names one whose build it is not given. Nil from a node-engine before it. + Holds map[string]Hold `json:",omitempty"` // SetAside is each module whose manifest has a field the reader's decoder does not know, by module, with what was set // aside (catalogue.Manifest.SetAside): the rest of it is composed, and the reader names it (ADR 0578 §6). SetAside map[string]string `json:",omitempty"` } +// What holds a module Refused names (Hold.By). +const ( + // HeldByVerdict is a module the indexer's judge refused the manifest of (its verdict). + HeldByVerdict = "verdict" + // HeldByDecoder is a module whose bytes this reader's decoder does not decode, or cannot read past a field it does not + // know: no judge of the manifest overrules it. + HeldByDecoder = "decoder" +) + +// Hold is what holds one module Refused names, and the build its node would compose it at (Input.Holds). +type Hold struct { + // By is HeldByVerdict or HeldByDecoder. + By string + // Build is the fingerprint the node would compose it at, its want's or else the indexer's newest; empty where it is not + // placed on the composing node or names no build. + Build string `json:",omitempty"` + // Built is its manifest as the node takes it in from that build, when the builder holds it done; nil while that build + // is awaited (or not asked: only a node's answer to a check asks it). + Built *catalogue.Manifest `json:",omitempty"` +} + // Issued is one pair credential made for a consumer of a provider (inv.Secret): ForProvider is its copy sealed to the // provider, optional. type Issued struct { -- 2.54.0 From d0fb384f00987e4e04ff174d6b14df5ee8d7587f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 7 Nov 2026 02:33:21 +0100 Subject: [PATCH 69/73] The check's composer composes a module the running indexer alone holds when the change's manifest is the check's to judge (issue 1921, milestone 47/G H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0578 §2 and §3: a case's accepted verdict, or none (the running checker drops the change's own), lifts the input's hold on a manifest the composer reads whole; a case's refusal and bytes that do not decode stay held. It is composed at the build the input carries (Input.Holds). Without that build the node is not judged and the verdict names the module, why it was held and that its build is awaited; where it cannot compose without it, it is composed held as its node-engine holds it, and said so. Never a silent pass (the lead's Q4 ruling). The issue 774 row now gives the runner the refused verdict the checker gives a manifest the change does not bring. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/composer/composer.go | 140 ++++++++++++++++++++++++++++- internal/composer/composer_test.go | 78 +++++++++++++++- 2 files changed, 213 insertions(+), 5 deletions(-) diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 2d97b9a..aeefe83 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -15,6 +15,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" + "maps" "slices" "sort" "strings" @@ -162,6 +163,8 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { if err != nil { return CaseVerdict{}, err } + held := base + base, awaited := overrule(base, c, cv.Manifests, shelf) assignments := map[string]*meshmoduleassigner.Assignments{} for node, raw := range c.Assignments { if string(raw) == "null" { @@ -189,9 +192,17 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { shelf[name] = m } // A module the input carries as its node-engine took it in from its build (SentInput) is composed so: the bus - // reader composes it from its build, never from the indexer's manifest a case gives. + // reader composes it from its build, never from the indexer's manifest a case gives. One the case overrules the + // node's hold on is composed at the build the node would compose it at (Input.Holds), else from the case's manifest. for name, m := range base.Catalogue { - shelf[name] = m + if _, over := awaited[name]; !over { + shelf[name] = m + } + } + for name, hold := range base.Holds { + if _, over := awaited[name]; over && hold.Built != nil { + shelf[name] = *hold.Built + } } // A module the input's node-engine holds as refused (compose.Input.Refused: its verdict, or bytes that do not decode) is // held here as there, and its read says why, whatever the case's own verdicts: the check then names the cause the node @@ -225,11 +236,134 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { } cv.Machines = map[string]Machine{} for _, node := range r.Machines { - cv.Machines[node] = machine(in, node, depth, r.Declarations) + m := machine(in, node, depth, r.Declarations) + if words := awaitedOn(in, node, awaited); words != "" { + // Composed without the build it runs at: named per node and module, never a silent pass (issue 1921). The + // machine is then not judged, as for a node whose running state its engine does not send (ADR 0593 §9). Where + // it does not compose without that build (a tool runner's bundle), it is composed with the module held as its + // node-engine holds it, and named so; a node that refuses then too is the change's refusal. + how := "composed from the change's manifest without its build: " + if !m.Composes && m.NotJudged == "" { + if again := machine(heldAgain(in, held, awaited), node, depth, r.Declarations); again.Composes { + m, how = again, "composed with it held as its node-engine holds it, since it does not compose without its build: " + } + } + words = how + words + if m.Composes { + m = Machine{NotJudged: words, Declared: m.Declared} + } else if m.NotJudged == "" { + m.Reason += "; " + words + } + } + cv.Machines[node] = m } return cv, nil } +// overrule lifts the hold of the input's node-engine on each module whose manifest the case carries and judges itself +// (novox/hq ADR 0578 §2): a module the input holds (Refused: the running indexer's verdict, or the running node-engine's +// decoder), whose bytes this composer reads whole, and that the case gives an accepted verdict or none (a reader takes a +// manifest with no verdict as accepted, ADR 0578 §3; the running checker drops the verdicts of the change's manifests, +// issue 1921). It is then no longer Refused nor held in how the node composes. A case's own refusal and bytes this +// composer does not decode stay held. awaited is each module lifted with no build the input carries: the words for the verdict, or +// "" where it is composed at its build. +func overrule(base compose.Input, c Case, reads map[string]ManifestRead, shelf map[string]catalogue.Manifest) (compose.Input, map[string]string) { + awaited := map[string]string{} + for name, why := range base.Refused { + hold := base.Holds[name] + if _, carried := c.Manifests[name]; !carried || !reads[name].Accepted || shelf[name].ReadNoFurther() { + continue + } + held := "held by the running node-engine" + switch hold.By { + case compose.HeldByVerdict: + held = "refused only by the running indexer" + case compose.HeldByDecoder: + held = "not read by the running node-engine's decoder" + } + judged := "accepted by the check's own validator" + if _, given := c.Verdicts[name]; !given { + judged = "given no verdict by the check, so taken as accepted (novox/hq ADR 0578 §3)" + } + awaited[name] = "" + if hold.Built == nil && compose.NamesBuild(shelf[name]) { + build := "its build" + if hold.Build != "" { + build += " " + hold.Build + } + awaited[name] = fmt.Sprintf("%s is %s (%s) and %s, and %s is awaited", name, held, why, judged, build) + } + } + if len(awaited) == 0 { + return base, awaited + } + base.Refused = maps.Clone(base.Refused) + for name := range awaited { + delete(base.Refused, name) + } + if how := base.ComposedHow; how != nil { + copied := *how + copied.Held = maps.Clone(how.Held) + for name := range awaited { + delete(copied.Held, name) + } + base.ComposedHow = &copied + } + return base, awaited +} + +// heldAgain is in with each module overrule lifted and gave no build held again as held, the input before overrule, +// holds it: its stub, Refused and how the node composes. +func heldAgain(in, held compose.Input, awaited map[string]string) compose.Input { + in.Catalogue = maps.Clone(in.Catalogue) + in.Refused = maps.Clone(in.Refused) + if in.Refused == nil { + in.Refused = map[string]string{} + } + if how := in.ComposedHow; how != nil { + copied := *how + copied.Held = maps.Clone(how.Held) + in.ComposedHow = &copied + } + for name, words := range awaited { + if words == "" { + continue + } + if stub, ok := held.Catalogue[name]; ok { + in.Catalogue[name] = stub + } else { + in.Catalogue[name] = compose.Stub(in.Catalogue[name]) + } + in.Refused[name] = held.Refused[name] + if in.ComposedHow != nil && held.ComposedHow != nil { + if why, was := held.ComposedHow.Held[name]; was { + if in.ComposedHow.Held == nil { + in.ComposedHow.Held = map[string]string{} + } + in.ComposedHow.Held[name] = why + } + } + } + return in +} + +// awaitedOn is the words of each module placed on node that is composed without its build (overrule), joined, sorted. +func awaitedOn(in compose.Input, node string, awaited map[string]string) string { + var out []string + for _, n := range in.Nodes { + if n.Name != node { + continue + } + for _, m := range n.Assigned { + if words := awaited[m]; words != "" { + out = append(out, words) + } + } + } + sort.Strings(out) + return strings.Join(out, "; ") +} + // HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings // of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input). func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held { diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 75cefeb..ece1004 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -188,7 +188,8 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { }) // Issue 774 (the group check of feat/l3-final 278aa5f0ccbe): a tool runner its node-engine holds by its manifest's // verdict is sent as its stub, and the input says it holds it: the sandbox composes the node with it held, never refuses - // it for its stub's missing bundle. An input from an engine before the mark is read by Refused alone, the same way. + // it for its stub's missing bundle. An input from an engine before the mark is read by Refused alone, the same way. The + // case gives the runner its refused verdict, as the checker does for a manifest the change does not bring. t.Run("a refused tool runner the engine sends as its stub", func(t *testing.T) { raw := `{"module": "runner", "version": "1", "tool-runner": true, ` + `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}` @@ -211,7 +212,9 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { for name, sent := range map[string]string{"with the mark": string(wire), "from an engine before it": older} { var r Request r = Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, - Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"runner": {Raw: json.RawMessage(raw)}}}}} + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"runner": {Raw: json.RawMessage(raw)}}, + Verdicts: bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"runner": {V: 1, Module: "runner", + State: meshcatalogclient.VerdictRefused, Problems: []string{"p"}}})}}} if err := json.Unmarshal([]byte(sent), &r.Input); err != nil { t.Fatal(err) } @@ -227,6 +230,77 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { } } }) + // ADR 0578 §2, §3 (issue 1921): a module its node-engine holds by the running indexer's verdict gives way when the case, + // judging the change's manifest itself, gives it an accepted verdict or none (the running checker drops the change's + // own), and this composer reads its bytes whole. It is composed at the build the input carries for it (Input.Holds); + // without one it is composed from the case's manifest and the node is not judged, naming it (never a silent pass), or, + // where it cannot compose without its build (a tool runner's bundle), composed held as its node-engine holds it, named + // so. Bytes that do not decode here, and a case's own refusal, stay held. + t.Run("a held module the case judges itself", func(t *testing.T) { + app := `{"module": "app", "version": "1", "resources": [{"id": "server", "type": "container", "name": "app-server", "artifact": "server"}], ` + + `"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}` + // A module that names a build and composes from its manifest without it. + plain := `{"module": "app", "version": "1", "resources": [{"id": "d", "type": "directory", "path": "/var/lib/app"}], ` + + `"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}` + runner := `{"module": "app", "version": "1", "tool-runner": true, ` + + `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}` + built, err := catalogue.Decode([]byte(`{"module": "app", "version": "1", "resources": [{"id": "built", "type": "directory", "path": "/var/lib/app-built"}]}`)) + if err != nil { + t.Fatal(err) + } + verdict := func(state string) map[string]json.RawMessage { + return bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"app": {V: 1, Module: "app", State: state, Problems: []string{"p"}}}) + } + const why = "the indexer's judge refused the manifest of app" + for _, row := range []struct { + name string + raw string + verdicts map[string]json.RawMessage + hold *compose.Hold + composes bool // the machine composes, judged + path string // a path declared on it + words string // in NotJudged, else in the read's refusal + }{ + {"an accepted verdict, its build carried", app, verdict(meshcatalogclient.VerdictAccepted), + &compose.Hold{By: compose.HeldByVerdict, Build: "fp1", Built: &built}, true, "/var/lib/app-built", ""}, + {"no verdict, its build carried", app, nil, &compose.Hold{By: compose.HeldByVerdict, Build: "fp1", Built: &built}, true, "/var/lib/app-built", ""}, + {"an accepted verdict, its build awaited", plain, verdict(meshcatalogclient.VerdictAccepted), &compose.Hold{By: compose.HeldByVerdict, Build: "fp1"}, false, "", + "composed from the change's manifest without its build: app is refused only by the running indexer (" + why + ") and accepted by the check's own validator, and its build fp1 is awaited"}, + {"no verdict, from an engine before holds", plain, nil, nil, false, "", + "app is held by the running node-engine (" + why + ") and given no verdict by the check, so taken as accepted"}, + {"a container, its image awaited", app, nil, &compose.Hold{By: compose.HeldByVerdict}, false, "", + "composed with it held as its node-engine holds it, since it does not compose without its build: app is refused only by the running indexer"}, + {"a tool runner, its bundle awaited", runner, nil, &compose.Hold{By: compose.HeldByVerdict}, false, "", + "composed with it held as its node-engine holds it, since it does not compose without its build: app is refused only by the running indexer"}, + {"bytes that do not decode", `{"module": "app", "version": "1", "resources": "none"}`, nil, &compose.Hold{By: compose.HeldByVerdict, Built: &built}, true, "", "does not decode"}, + {"the case's own refusal", app, verdict(meshcatalogclient.VerdictRefused), &compose.Hold{By: compose.HeldByVerdict, Built: &built}, true, "", "refused the manifest of app"}, + } { + man, _ := catalogue.Decode([]byte(app)) + in := compose.Input{Catalogue: map[string]catalogue.Manifest{"app": compose.Stub(man)}, Refused: map[string]string{"app": why}, + Nodes: []compose.Node{{Name: "a", Assigned: []string{"app"}}}} + if row.hold != nil { + in.Holds = map[string]compose.Hold{"app": *row.hold} + } + v, err := Run(withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, Declarations: true, + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"app": {Raw: json.RawMessage(row.raw)}}, Verdicts: row.verdicts}}}, in)) + if err != nil { + t.Fatal(err) + } + m, read := v.Cases[0].Machines["a"], v.Cases[0].Manifests["app"] + declared := false + for _, p := range m.Declared { + declared = declared || p.Path == row.path + } + switch { + case m.Composes != row.composes || (row.path != "" && !declared): + t.Errorf("%s: machine %+v", row.name, m) + case !row.composes && (m.NotJudged == "" || !strings.Contains(m.NotJudged, row.words)): + t.Errorf("%s: not judged %q, want it naming %q", row.name, m.NotJudged, row.words) + case row.composes && row.path == "" && (read.Accepted || !strings.Contains(read.Refused, row.words)): + t.Errorf("%s: read %+v, want held naming %q", row.name, read, row.words) + } + } + }) v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}) if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" { t.Fatalf("a request with no machines: %+v, %v", v, err) -- 2.54.0 From 5b75e866d07948819904f009675f5aa8edb8df0e Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 7 Nov 2026 02:38:18 +0100 Subject: [PATCH 70/73] The composition verb says what holds each held module and the build it would be composed at (issue 1921, milestone 47/G H4) The node's own composition named a held module's why only; it now also says whether its manifest's verdict or this node-engine's decoder holds it, and at which build, as the input a check's composer reads says it. Said in the held words, so the node-engine seat's answer keeps its shape. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/compose/frombus/frombus.go | 14 ++++++++++ internal/selfcompose/selfcompose.go | 35 +++++++++++++++++++++--- internal/selfcompose/selfcompose_test.go | 6 ++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 7aabec4..18e45a0 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -171,6 +171,10 @@ type Scope struct { // on the node that its manifest's verdict or its decoder holds, into compose.Input.Holds, never into what the node composes: the // check's composer, judging the change's manifest itself, composes it at that build (issue 1921). takeInHeld bool + // Holds is told, after Compose read the input, what holds each module it holds by its manifest's verdict or its + // decoder (compose.Input.Holds), with the build it would compose it at, so the node says why it holds it (issue 1921, + // the composition verb). Nil: said nowhere. + Holds func(map[string]compose.Hold) } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -590,6 +594,16 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s if err != nil { return nil, nil, held, err } + if s.Holds != nil { + holds := map[string]compose.Hold{} + for name := range held { + if h, said := in.Holds[name]; said { + h.Build = fps[name] + holds[name] = h + } + } + s.Holds(holds) + } // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5). asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) if err != nil { diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index a0f89ad..989c5ac 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -235,6 +235,9 @@ type Pass struct { Refused string `json:"refused,omitempty"` // Held is each module refused alone, with why. Held map[string]string `json:"held,omitempty"` + // holds is what holds each module Held names by its manifest's verdict or its decoder, with the build it would be + // composed at (frombus.Scope.Holds), for LastHeld's words. + holds map[string]compose.Hold // Handed is how many compositions the pass handed the agent that differ from what it handed before. Handed int `json:"handed"` // Again is when the node composes again on its own, after a seat or a read that did not answer; zero when it does @@ -247,11 +250,34 @@ type Pass struct { Queued string `json:"queued,omitempty"` } -// LastHeld is what the last pass held, each module with why; nil before a pass or when it held none. +// LastHeld is what the last pass held, each module with why: for one its manifest's verdict or its decoder holds, also +// that and the build it would be composed at, as the input a check's composer reads says it (compose.Input.Holds, +// issue 1921); nil before a pass or when it held none. func (c *Composer) LastHeld() map[string]string { c.mu.Lock() defer c.mu.Unlock() - return maps.Clone(c.last.Held) + out := maps.Clone(c.last.Held) + for name, h := range c.last.holds { + if why, held := out[name]; held { + out[name] = why + " (" + holdWords(h) + ")" + } + } + return out +} + +// holdWords is what holds a module and the build it would be composed at, in words. +func holdWords(h compose.Hold) string { + by := "held by " + h.By + switch h.By { + case compose.HeldByVerdict: + by = "held by its manifest's verdict" + case compose.HeldByDecoder: + by = "held by this node-engine's decoder" + } + if h.Build == "" { + return by + ", at no build" + } + return by + ", at the build " + h.Build } // askedWithin is how recent a pass answers an ask of the verb compose without a new one: the first wait after a seat @@ -919,6 +945,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { // module's have, from which the watchman raises its condition. keptRuns := map[string][]string{} var keptMu sync.Mutex + var holds map[string]compose.Hold scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: func(module, words string) { keptMu.Lock() defer keptMu.Unlock() @@ -926,7 +953,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { keptRuns[module] = append(keptRuns[module], words) } c.sayOnceEach(words) - }} + }, Holds: func(h map[string]compose.Hold) { holds = h }} c.mu.Lock() c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.mu.Unlock() @@ -985,7 +1012,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { return nil } c.paid() - pass.Held = held + pass.Held, pass.holds = held, holds // The provisioner client's account the node runs with, dropped by this pass, is said: every filled write is refused // under a declaration that names none (filling.JudgeAccount), and no module is handed to it (issue 774, ace, // 2026-11-04 01:29). A placed client keeps its account while it is held (compose.Node.RunningProvisioner). diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index fc714ca..701aa48 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -508,6 +508,12 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if len(*told4) != 1 || !strings.HasPrefix((*told4)[0], "held power: ") || !strings.Contains((*told4)[0], `"shows" is none of them`) { t.Errorf("the agent was told %q, want power held alone, naming its item", *told4) } + // Issue 1921: the composition verb's held says what holds power and the build it would be composed at, as the input a + // check's composer reads says it. + if why := c4.LastHeld()["power"]; !strings.Contains(why, `"shows" is none of them`) || + !strings.Contains(why, "held by its manifest's verdict, at the build "+powerFP) { + t.Errorf("the last pass held power for %q, want its verdict and its build named", why) + } // Review of issue 774 (the kept read): mesh-wireguard, which the mesh provides and which never has a fingerprint, is // folded in the node's own and composed from the manifest mesh-sdk provides (overlay.ProvidedManifest), not from // what runs nor the kept declaration: with nothing running (the kept read retired, Running empty) the node's own -- 2.54.0 From b6c174a324c69c41be647c11042ca2a4a3a1724c Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 7 Nov 2026 07:19:58 +0100 Subject: [PATCH 71/73] A consumer beside a held provider is told what it serves without the names it does not compose, never their placeholders (issue 1921, milestone 47/G) The provider held on its machine is composed apart, so no module of the composed set serves the provision; its manifest's served values reached the consumer's binding raw and the binding refused the consumer for a ${bound:route:name-web} it does not require (the group check of 8a80e8e86858 on novox, mesh-build-node beside a held gitea). Each such key is now left out and said on the need, as a provider elsewhere leaves it out (ADR 0346). Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/compose/resolution/declaration.go | 32 +++++++++++++++++++ .../resolution/served_own_names_test.go | 24 ++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go index 4f4e73a..6f0f165 100644 --- a/internal/compose/resolution/declaration.go +++ b/internal/compose/resolution/declaration.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "slices" "sort" "strconv" "strings" @@ -240,7 +242,14 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] } if answered { r.Needs[i].Serves = serves + continue } + // No module of the composed set serves it: its provider is held on this machine (composed apart, as what runs of + // it), and offers what its manifest serves. None of its own names is composed here, so a key that names one is + // left out and said on the need, as a provider elsewhere leaves it out (ServedLeftOut, novox/hq ADR 0346 §3), + // never handed to the consumer as a placeholder its binding then refuses (issue 1921, novox: "mesh-build-node has + // a file that says ${bound:route:name-web}, and mesh-build-node does not require route"). + r.Needs[i].Serves, r.Needs[i].LeftOut = withoutOwnNames(r.Needs[i], r.Node) } // Once, from every module's listens -- not per module. A module receiving only its own ports @@ -2526,3 +2535,26 @@ func resolutionGeneratedHere(r mcat.Resolution, with mcat.Rendering) (map[string } return generated, nil } + +// withoutOwnNames is what a need's provider serves without each key whose value names one of the provider's own composed +// names (${bound:…}), and the need's left-out keys with each such key said: a provider not composed in this set +// composes none of its names here. +func withoutOwnNames(n mcat.Needed, node string) (map[string]any, []string) { + var asking []string + for _, key := range sortedAnyKeys(n.Serves) { + if text, ok := n.Serves[key].(string); ok && bound.MatchString(text) { + asking = append(asking, key) + } + } + if len(asking) == 0 { + return n.Serves, n.LeftOut + } + out := maps.Clone(n.Serves) + said := slices.Clone(n.LeftOut) + for _, key := range asking { + delete(out, key) + said = append(said, fmt.Sprintf("%s serves %s without %q: it names %s, and %s composes no names of %s, which it "+ + "holds (novox/hq ADR 0346)", n.Module, n.Name, key, bound.FindString(n.Serves[key].(string)), orHere(node), n.Module)) + } + return out, said +} diff --git a/internal/compose/resolution/served_own_names_test.go b/internal/compose/resolution/served_own_names_test.go index 7586e93..b29f4b8 100644 --- a/internal/compose/resolution/served_own_names_test.go +++ b/internal/compose/resolution/served_own_names_test.go @@ -1,6 +1,7 @@ package resolution import ( + "slices" "strings" "testing" @@ -75,6 +76,29 @@ func TestAConsumerBesideTheProviderIsServedItsName(t *testing.T) { if strings.Contains(binding, "${bound:") { t.Fatalf("a placeholder reached the consumer:\n%s", binding) } + // Issue 1921 (the group check of 8a80e8e86858, novox): the provider held on this machine (composed apart, as what runs + // of it) is not in the composed set, so none of its own names is composed here. The consumer beside it is told + // what it serves without the keys that name them, each said on the need, never the placeholder: its binding refused + // the consumer with "has a file that says ${bound:route:name-web}, and lab does not require route". + held, err := Resolve(shelf(minimalRouteProxy(), m, consumer), []string{"route-proxy", "forge", "lab"}, node, mcat.World{}) + if err != nil { + t.Fatal(err) + } + held.Modules = slices.DeleteFunc(slices.Clone(held.Modules), func(m mcat.Manifest) bool { return m.Module == "forge" }) + out, err = Declaration(held, mcat.Rendering{}) + if err != nil { + t.Fatalf("a consumer beside a held provider: %v", err) + } + for _, r := range out { + if c, _ := r["content"].(string); r["path"] == "/var/lib/lab/package-registry.json" && (strings.Contains(c, "${bound:") || !strings.Contains(c, "npm-path")) { + t.Fatalf("the binding beside a held provider:\n%s", c) + } + } + for _, n := range held.Needs { + if n.Name == "npm-package-registry" && (len(n.LeftOut) != 1 || !strings.Contains(n.LeftOut[0], `"public-root"`)) { + t.Fatalf("the need beside a held provider says not what was left out: %+v", n) + } + } } // From another machine, the provider's served values carry what that machine composed, and a key it -- 2.54.0 From 53addd73c358daae84e24da7e5cd9227b9901879 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 7 Nov 2026 07:24:16 +0100 Subject: [PATCH 72/73] The check's composer composes a module at its build only where that build was made of the change's manifest (issue 1921, milestone 47/G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The running indexer could not read 17 of the group's manifests, so the running node-engine composed those modules at their trunk builds and said nothing; the head composer laid those builds over the change's other manifests and ace failed on "nothing provides artifact-store, wanted by mesh-build-go" (group check 8a80e8e86858). The input a node answers a check with now names, per module taken in from its build, the content digest of the manifest that build was made of (ComposedHow.Manifests); the composer compares it with the case's manifest, and, for an input from a node-engine before it, compares what the two manifests say beyond what a take-in resolves (the lead's ruling, §17). Each module lifted either way is composed from the case's manifest, its build awaited, and named in NotJudged with the comparison that lifted it; one that cannot compose without its build is held as its stub. The digest is taken over one JSON form because a request through a build module arrives compacted. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/compose/frombus/answer_rows_test.go | 10 +++ internal/compose/frombus/frombus.go | 15 +++- internal/compose/input.go | 25 ++++++ internal/composer/composer.go | 89 +++++++++++++++++++- internal/composer/composer_test.go | 49 +++++++++++ 5 files changed, 185 insertions(+), 3 deletions(-) diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 15c33bc..eeb0894 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -1,6 +1,7 @@ package frombus import ( + "bytes" "context" "encoding/json" "fmt" @@ -218,6 +219,15 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.Fp2, err, read.ComposedHow, raw) } + // Issue 1921: the input a check is answered with names the manifest spiegel's build was made of (its content + // digest, the same for those bytes compacted inside a request), so the check's composer composes it at that + // build only where the change's manifest is that one. + var compact bytes.Buffer + _ = json.Compact(&compact, bb.manifests[sp.Manifest]) + want, _ := compose.ContentDigest(bb.manifests[sp.Manifest]) + if got, _ := compose.ContentDigest(compact.Bytes()); err == nil && (want == "" || read.ComposedHow.Manifests["spiegel"] != want || got != want) { + t.Errorf("the manifest spiegel's build was made of: %v, want %s (compacted %s)", read.ComposedHow.Manifests, want, got) + } // Issue 1921: held by its manifest's verdict, spiegel is still held in what the node composes, and the input a check // is answered with says what holds it and carries the build the node would compose it at, taken in while done; a // node's own read takes in no build of it. diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 18e45a0..58bda5b 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -171,6 +171,9 @@ type Scope struct { // on the node that its manifest's verdict or its decoder holds, into compose.Input.Holds, never into what the node composes: the // check's composer, judging the change's manifest itself, composes it at that build (issue 1921). takeInHeld bool + // manifests, set with takeInHeld, is filled with the ContentDigest of the manifest each module placed on the node was + // built from, as it is taken in from that build (compose.ComposedHow.Manifests). + manifests map[string]string // Holds is told, after Compose read the input, what holds each module it holds by its manifest's verdict or its // decoder (compose.Input.Holds), with the build it would compose it at, so the node says why it holds it (issue 1921, // the composition verb). Nil: said nowhere. @@ -624,12 +627,12 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s // credential: the sandbox composes no pair value. func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (compose.Input, error) { held := map[string]string{} - s.takeInHeld = true + s.takeInHeld, s.manifests = true, map[string]string{} in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps) if err != nil { return compose.Input{}, err } - c := &compose.ComposedHow{Node: s.Node, Builds: maps.Clone(fps), Held: held} + c := &compose.ComposedHow{Node: s.Node, Builds: maps.Clone(fps), Held: held, Manifests: s.manifests} for _, run := range s.Running { if _, isHeld := held[run.Module]; isHeld && run.Module != "" { c.Running = append(c.Running, run) @@ -983,6 +986,14 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m continue } in.Catalogue[pm.Module] = resolved + if s.manifests != nil { + // Unread or not JSON, it is left out: the check's composer then compares what the manifests say (issue 1921). + if raw, err := r.Manifest(ctx, b.Manifest); err == nil { + if digest, err := compose.ContentDigest(raw); err == nil { + s.manifests[pm.Module] = digest + } + } + } } for i := range in.Nodes { if in.Nodes[i].Name != s.Node { diff --git a/internal/compose/input.go b/internal/compose/input.go index 74c7c8d..7c1de99 100644 --- a/internal/compose/input.go +++ b/internal/compose/input.go @@ -16,6 +16,9 @@ package compose import ( + "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -283,6 +286,28 @@ type ComposedHow struct { // HeldBefore is each module the node holds composing without the change (the candidate's base), held by the read or // blamed later for a refusal: the change refuses none of them (issue 774, review round 1). HeldBefore map[string]string + // Manifests is, by module taken in from its build, the ContentDigest of the manifest that build was made of (its + // build record's manifest), filled in the input a node answers a check with: the check's composer composes a module + // at its build only where the change's manifest is that one (issue 1921). Nil from a node-engine before it. + Manifests map[string]string +} + +// ContentDigest is the hex sha256 of a manifest's JSON in one form, its keys sorted and its spacing removed, its numbers +// as written: the same for the bytes its repository holds and for those bytes carried inside a request, which JSON +// encoding compacts on the way (a composition request through a build module), so the two are compared as one manifest. +func ContentDigest(raw []byte) (string, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return "", fmt.Errorf("compose: the manifest is not JSON: %w", err) + } + canonical, err := json.Marshal(v) + if err != nil { + return "", err + } + sum := sha256.Sum256(canonical) + return hex.EncodeToString(sum[:]), nil } // Place is where a node is, as its assignment says (inv.Overlay). diff --git a/internal/composer/composer.go b/internal/composer/composer.go index aeefe83..72e3db2 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -294,6 +294,53 @@ func overrule(base compose.Input, c Case, reads map[string]ManifestRead, shelf m awaited[name] = fmt.Sprintf("%s is %s (%s) and %s, and %s is awaited", name, held, why, judged, build) } } + // The same case where the running indexer cannot read the change's manifest at all (issue 1921, the group check of + // 8a80e8e86858): it gives that manifest no fingerprint, so the running node-engine composes the module at the build + // it runs, of the trunk's manifest, holds nothing and says nothing. Composing that build over the case's other + // manifests mixed two revisions (mesh-build-go's trunk build requiring a provision the change's manifests no longer + // name). A module the case carries that the input composes at a build of another manifest is composed from the + // case's manifest, its build awaited, named with the comparison that found it: the content digest of the manifest + // its build was made of where the input names one (ComposedHow.Manifests), else, from a node-engine before it, what + // the two manifests say once what a take-in resolves is left out (sameContract). + provided := map[string]bool{} + for _, name := range overlay.ProvidedModules() { + provided[name] = true // its manifest comes with mesh-sdk and always wins (runCase) + } + for name, built := range base.Catalogue { + if _, lifted := awaited[name]; lifted || provided[name] { + continue + } + if _, carried := c.Manifests[name]; !carried || !reads[name].Accepted || shelf[name].ReadNoFurther() { + continue + } + how := base.ComposedHow + var compared string + if digest := manifestOf(how, name); digest != "" { + if carried, err := compose.ContentDigest(c.Manifests[name].Raw); err == nil && carried == digest { + continue + } + compared = "its build was made of the manifest " + digest + ", not the change's" + } else { + if sameContract(built, shelf[name]) { + continue + } + compared = "its node-engine names no manifest its build was made of, and what the two manifests say differs " + + "beyond what a take-in resolves" + } + judged := "accepted by the check's own validator" + if _, given := c.Verdicts[name]; !given { + judged = "given no verdict by the check, so taken as accepted (novox/hq ADR 0578 §3)" + } + at := "" + if how != nil && how.Builds[name] != "" { + at = " " + how.Builds[name] + } + awaited[name] = "" + if compose.NamesBuild(shelf[name]) { + awaited[name] = fmt.Sprintf("%s is composed by the running node-engine at its build%s, of another manifest than the "+ + "change's (%s), and %s, and its build of the change's manifest is awaited", name, at, compared, judged) + } + } if len(awaited) == 0 { return base, awaited } @@ -329,12 +376,27 @@ func heldAgain(in, held compose.Input, awaited map[string]string) compose.Input if words == "" { continue } + why, was := held.Refused[name] + if !was { + // Composed by the running node-engine at a build of another manifest, not held (overrule): held here as a node + // holds a module whose build has not landed, the change's manifest as its stub, so that build is not laid over + // the change's other manifests again. + in.Catalogue[name] = compose.Stub(in.Catalogue[name]) + in.Refused[name] = words + if in.ComposedHow != nil { + if in.ComposedHow.Held == nil { + in.ComposedHow.Held = map[string]string{} + } + in.ComposedHow.Held[name] = words + } + continue + } if stub, ok := held.Catalogue[name]; ok { in.Catalogue[name] = stub } else { in.Catalogue[name] = compose.Stub(in.Catalogue[name]) } - in.Refused[name] = held.Refused[name] + in.Refused[name] = why if in.ComposedHow != nil && held.ComposedHow != nil { if why, was := held.ComposedHow.Held[name]; was { if in.ComposedHow.Held == nil { @@ -517,3 +579,28 @@ func SentInput(in compose.Input, node string) compose.Input { } return in } + +// manifestOf is the content digest of the manifest module's build was made of, as the input names it; "" when it names +// none (a node-engine before ComposedHow.Manifests, or a build it did not read). +func manifestOf(how *compose.ComposedHow, module string) string { + if how == nil { + return "" + } + return how.Manifests[module] +} + +// sameContract says whether the manifest a module was taken in from its build at (built) and the manifest a case carries +// for it (carried) compose alike: equal once what a take-in resolves (the build, its bundles, the templates filled into +// resources and items, what is handed) is left out of both. +func sameContract(built, carried catalogue.Manifest) bool { + contract := func(m catalogue.Manifest) string { + m.Handed, m.Build, m.Templates, m.Resources, m.Bundles, m.Items = nil, nil, "", nil, nil, nil + raw, err := json.Marshal(m) + if err != nil { + return "" + } + return string(raw) + } + b, c := contract(built), contract(carried) + return b != "" && b == c +} diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index ece1004..51bced2 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -301,6 +301,55 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { } } }) + // Issue 1921 (the group check of 8a80e8e86858, ace: "nothing provides artifact-store, wanted by mesh-build-go"): a + // module the input composes at its build, taken in by a node-engine whose indexer cannot read the change's manifest, + // is composed at that build only where the build was made of the case's manifest: by the content digest the input + // names, else, from a node-engine that names none, by what the two manifests say beyond what a take-in resolves. + // Otherwise it is composed from the case's manifest, never silently: the node is not judged, naming it and how. + t.Run("a module composed at a build of another manifest", func(t *testing.T) { + plain := `{"module": "app", "version": "1", "resources": [{"id": "d", "type": "directory", "path": "/var/lib/app"}], ` + + `"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}` + built, err := catalogue.Decode([]byte(`{"module": "app", "version": "1", "resources": [{"id": "built", "type": "directory", "path": "/var/lib/app-built"}]}`)) + if err != nil { + t.Fatal(err) + } + same, err := compose.ContentDigest([]byte(plain)) + if err != nil { + t.Fatal(err) + } + for _, row := range []struct { + name, raw, digest string + words string // in NotJudged, its build's paths not declared; "" composes at its build + }{ + {"the digest of the case's manifest", plain, same, ""}, + {"another digest, the same contract", plain, strings.Repeat("b", 64), "of another manifest than the change's (its build was made of the manifest " + strings.Repeat("b", 64)}, + {"no digest, the same contract", plain, "", ""}, + {"no digest, another contract", strings.Replace(plain, `"version": "1"`, `"version": "2"`, 1), "", + "app is composed by the running node-engine at its build fp1, of another manifest than the change's (its node-engine names no manifest"}, + {"a tool runner, its bundle awaited", `{"module": "app", "version": "1", "tool-runner": true, ` + + `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}`, "", + "composed with it held as its node-engine holds it, since it does not compose without its build: app is composed by the running node-engine at its build fp1"}, + } { + in := compose.Input{Catalogue: map[string]catalogue.Manifest{"app": built}, Nodes: []compose.Node{{Name: "a", Assigned: []string{"app"}}}, + ComposedHow: &compose.ComposedHow{Node: "a", Builds: map[string]string{"app": "fp1"}}} + if row.digest != "" { + in.ComposedHow.Manifests = map[string]string{"app": row.digest} + } + v, err := Run(withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, Declarations: true, + Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"app": {Raw: json.RawMessage(row.raw)}}}}}, in)) + if err != nil { + t.Fatal(err) + } + m := v.Cases[0].Machines["a"] + declared := false + for _, p := range m.Declared { + declared = declared || p.Path == "/var/lib/app-built" + } + if row.words == "" && (!m.Composes || !declared) || row.words != "" && (m.Composes || declared || !strings.Contains(m.NotJudged, row.words)) { + t.Errorf("%s: machine %+v, want it naming %q", row.name, m, row.words) + } + } + }) v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}) if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" { t.Fatalf("a request with no machines: %+v, %v", v, err) -- 2.54.0 From cb7a444a5634e9d202df0cb250aa3f67e1366582 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 7 Nov 2026 08:19:55 +0100 Subject: [PATCH 73/73] The check's composer says a node's refusal in the words of its composition with the lifted modules held (issue 1921, milestone 47/G) When a node refused also with the modules composed without their build held as its node-engine holds them, the verdict kept the words of the composition from their unbuilt manifests, whose refusals the lift itself made (a tool runner with no bundle, a resource whose artifact or template no build resolved). In the group check of c5d32074c945 that hid lab's real refusal on shanks among them. The node's refusal is now that of the held composition, named so. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- internal/composer/composer.go | 10 ++++++-- internal/composer/composer_test.go | 37 +++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/internal/composer/composer.go b/internal/composer/composer.go index 72e3db2..5b2682e 100644 --- a/internal/composer/composer.go +++ b/internal/composer/composer.go @@ -241,11 +241,17 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) { // Composed without the build it runs at: named per node and module, never a silent pass (issue 1921). The // machine is then not judged, as for a node whose running state its engine does not send (ADR 0593 §9). Where // it does not compose without that build (a tool runner's bundle), it is composed with the module held as its - // node-engine holds it, and named so; a node that refuses then too is the change's refusal. + // node-engine holds it, and named so; a node that refuses then too is the change's refusal, in the words of that + // composition, never of the one from the unbuilt manifests, whose refusals the lift itself made (a tool runner + // with no bundle, a resource whose artifact or template no build resolved: the group check of c5d32074c945 on + // shanks). how := "composed from the change's manifest without its build: " if !m.Composes && m.NotJudged == "" { - if again := machine(heldAgain(in, held, awaited), node, depth, r.Declarations); again.Composes { + switch again := machine(heldAgain(in, held, awaited), node, depth, r.Declarations); { + case again.Composes: m, how = again, "composed with it held as its node-engine holds it, since it does not compose without its build: " + case again.NotJudged == "": + m, how = again, "refused with it held as its node-engine holds it, since it does not compose without its build: " } } words = how + words diff --git a/internal/composer/composer_test.go b/internal/composer/composer_test.go index 51bced2..1dc0792 100644 --- a/internal/composer/composer_test.go +++ b/internal/composer/composer_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -317,30 +318,50 @@ func TestARequestItCannotRunIsRefused(t *testing.T) { if err != nil { t.Fatal(err) } + runner := `{"module": "app", "version": "1", "tool-runner": true, ` + + `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}` for _, row := range []struct { name, raw, digest string words string // in NotJudged, its build's paths not declared; "" composes at its build + beside string // a module the change refuses on the same node: words are then in the refusal }{ - {"the digest of the case's manifest", plain, same, ""}, - {"another digest, the same contract", plain, strings.Repeat("b", 64), "of another manifest than the change's (its build was made of the manifest " + strings.Repeat("b", 64)}, - {"no digest, the same contract", plain, "", ""}, + {"the digest of the case's manifest", plain, same, "", ""}, + {"another digest, the same contract", plain, strings.Repeat("b", 64), "of another manifest than the change's (its build was made of the manifest " + strings.Repeat("b", 64), ""}, + {"no digest, the same contract", plain, "", "", ""}, {"no digest, another contract", strings.Replace(plain, `"version": "1"`, `"version": "2"`, 1), "", - "app is composed by the running node-engine at its build fp1, of another manifest than the change's (its node-engine names no manifest"}, - {"a tool runner, its bundle awaited", `{"module": "app", "version": "1", "tool-runner": true, ` + - `"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}`, "", - "composed with it held as its node-engine holds it, since it does not compose without its build: app is composed by the running node-engine at its build fp1"}, + "app is composed by the running node-engine at its build fp1, of another manifest than the change's (its node-engine names no manifest", ""}, + {"a tool runner, its bundle awaited", runner, "", + "composed with it held as its node-engine holds it, since it does not compose without its build: app is composed by the running node-engine at its build fp1", ""}, + // The group check of c5d32074c945, shanks: the change refused lab there, and the words were those of the + // composition from the change's manifests unbuilt ("mesh-build-go runs as its own account and its build + // produced no bundle to serve"), not of the one with them held, so the lift's own refusals failed the node. + {"a tool runner awaited beside a module the change refuses", runner, "", + "refused with it held as its node-engine holds it, since it does not compose without its build: app is composed by the running node-engine at its build fp1", + `{"module": "office", "version": "1", "resources": [{"id": "c", "type": "file", "path": "/etc/office.conf", "template": "x"}]}`}, } { in := compose.Input{Catalogue: map[string]catalogue.Manifest{"app": built}, Nodes: []compose.Node{{Name: "a", Assigned: []string{"app"}}}, ComposedHow: &compose.ComposedHow{Node: "a", Builds: map[string]string{"app": "fp1"}}} if row.digest != "" { in.ComposedHow.Manifests = map[string]string{"app": row.digest} } + manifests := map[string]Manifest{"app": {Raw: json.RawMessage(row.raw)}} + if row.beside != "" { + manifests["office"] = Manifest{Raw: json.RawMessage(row.beside)} + in.Nodes[0].Assigned = append(in.Nodes[0].Assigned, "office") + } v, err := Run(withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose, Declarations: true, - Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"app": {Raw: json.RawMessage(row.raw)}}}}}, in)) + Cases: []Case{{Name: "change", Manifests: manifests}}}, in)) if err != nil { t.Fatal(err) } m := v.Cases[0].Machines["a"] + if row.beside != "" { + said := fmt.Sprint(m.Reason, m.Problems, m.Refusals) + if m.Composes || m.NotJudged != "" || !strings.Contains(said, "office") || strings.Contains(said, "bundle") || !strings.Contains(m.Reason, row.words) { + t.Errorf("%s: machine %+v, want the change's refusal of office with app held, naming %q", row.name, m, row.words) + } + continue + } declared := false for _, p := range m.Declared { declared = declared || p.Path == "/var/lib/app-built" -- 2.54.0