From e2622fd0319e3283e69483a5d413c231aac981ce Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:50:25 +0200 Subject: [PATCH] An act says the unmet seat dependencies of the node it acted on, not the mesh's (hq ADR 0207) assign and unassign say only what they changed on their node; push lists that node's, push to many counts each and points at status. The once-per-change log is the serving controller's alone: a one-shot command starts with no memory, so it logged every node on every call. --- cmd/mesh-controller/acts.go | 44 ++++++--- cmd/mesh-controller/plan.go | 67 +++++++++++++- cmd/mesh-controller/push.go | 30 ++++++ cmd/mesh-controller/unheld_report_test.go | 108 ++++++++++++++++++++++ 4 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 cmd/mesh-controller/unheld_report_test.go diff --git a/cmd/mesh-controller/acts.go b/cmd/mesh-controller/acts.go index 47480ba..45b054a 100644 --- a/cmd/mesh-controller/acts.go +++ b/cmd/mesh-controller/acts.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "github.com/novox/mesh-controller/internal/broker" + "slices" "sort" "strings" @@ -56,7 +57,7 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) ( // — the service manager, the package manager and the runtime before anything that installs, // runs or contains — is the mesh's to keep. Several modules in one act are judged together, so // holders that depend on each other go on in one command. - said, err := seatDependenciesOnAssign(ctx, open, node, modules) + shelf, before, err := seatDependenciesOnAssign(ctx, open, node, modules) if err != nil { return "", err } @@ -91,8 +92,11 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) ( for _, line := range settled { answer += "\n " + line } - for _, line := range said { - answer += "\n but " + line + // What this act changed about this node's unmet seat dependencies, and nothing else (novox/hq + // ADR 0207): a dependency of a module just assigned, or one this assignment met. The rest of the + // node's list, and every other node's, is `status`'s. + for _, line := range unheldChange(shelf, node, before, append(append([]string(nil), before...), added...)) { + answer += "\n " + line } // Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its // credential exists delivers a process that cannot authenticate and crash-loops until somebody @@ -128,17 +132,18 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) ( blockedElsewhere(ctx, open, node), nil } -// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or the lines an assignment -// says beside itself when a dependency has no holder in the catalogue to name. Modules already -// assigned are not new and are not judged again. -func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) ([]string, error) { +// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or nothing, with the +// catalogue and the node's assignments it was judged against. Modules already assigned are not new +// and are not judged again. +func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) ( + map[string]catalogue.Manifest, []string, error) { shelf, err := open.inventory.Catalogue(ctx) if err != nil { - return nil, err + return nil, nil, err } assigned, err := open.inventory.Assigned(ctx, node) if err != nil { - return nil, err + return nil, nil, err } already := map[string]bool{} for _, a := range assigned { @@ -150,7 +155,12 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo adding = append(adding, m) } } - return catalogue.AssignRefusal(shelf, node, assigned, adding) + // The lines AssignRefusal says beside an assignment it lets through are said by unheldChange + // with everything else this act changed, so they are not said twice. + if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil { + return nil, nil, err + } + return shelf, assigned, nil } // unassign takes modules off a node. What they leave behind is the host's business: a directory @@ -198,8 +208,18 @@ func unassign(ctx context.Context, open *stores, node string, modules ...string) return "", err } } - return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so", - node, strings.Join(modules, ", "), node) + blockedElsewhere(ctx, open, node), nil + answer := fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so", + node, strings.Join(modules, ", "), node) + var left []string + for _, a := range assigned { + if !slices.Contains(modules, a) { + left = append(left, a) + } + } + for _, line := range unheldChange(shelf, node, assigned, left) { + answer += "\n " + line + } + return answer + blockedElsewhere(ctx, open, node), nil } // splitModules is a surface's one `module` field as the modules it names: several, comma-separated, diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 4f81b17..c935540 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -8,6 +8,7 @@ import ( "flag" "fmt" "os" + "slices" "sort" "strings" "sync" @@ -1329,16 +1330,25 @@ func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.C } // unheldLogged is what was last logged about each node's unmet seat dependencies, so the log says -// each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document — planFor runs for every status, push and assignment, and a -// line per call would bury the one that changed. +// each change once (novox/hq ADR 0207), on stderr so `status --json` stays a document. +// +// **The serving controller's log only.** planFor runs for every node on every push, assignment and +// status — `blockedElsewhere` alone resolves the whole mesh — and a one-shot command starts with an +// empty memory, so every node's report was "a change" and a push printed the whole mesh's list, +// burying the line about the node it acted on. A command says what concerns its own act instead +// (unheldChange, reportUnheldPushed); the full list is `status`'s. var ( - unheldLogged = map[string]string{} - unheldLoggedMu sync.Mutex + unheldLogged = map[string]string{} + unheldLoggedMu sync.Mutex + logUnheldChanges bool ) // logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for -// it, including when they become none. +// it, including when they become none — in the serving controller, and nowhere else. func logUnheld(node string, unheld []catalogue.Unheld) { + if !logUnheldChanges { + return + } lines := make([]string, 0, len(unheld)) for _, u := range unheld { lines = append(lines, u.String()) @@ -1358,3 +1368,50 @@ func logUnheld(node string, unheld []catalogue.Unheld) { fmt.Fprintf(os.Stderr, "%s: %d unmet seat dependenc(ies), reported and not refused (novox/hq ADR 0207):\n %s\n", node, len(lines), strings.Join(lines, "\n ")) } + +// unheldChange is what an act on one node changed about its unmet seat dependencies, judged over +// its assignments before and after (novox/hq ADR 0207): each dependency now unmet that was not — +// which includes every one of a module just assigned — and each now met that was not. Nothing about +// any other node, and nothing that was already true before the act. +func unheldChange(shelf map[string]catalogue.Manifest, node string, before, after []string) []string { + judge := func(names []string) map[string]catalogue.Unheld { + var set []catalogue.Manifest + for _, n := range names { + if m, known := shelf[n]; known { + set = append(set, m) + } + } + out := map[string]catalogue.Unheld{} + for _, u := range catalogue.UnheldDependencies(shelf, node, set, nil) { + out[u.Module+" "+u.Seat] = u + } + return out + } + was, now := judge(before), judge(after) + var lines []string + for _, k := range sortedNames(now) { + if _, already := was[k]; !already { + lines = append(lines, "but "+now[k].String()) + } + } + for _, k := range sortedNames(was) { + if _, still := now[k]; still { + continue + } + u := was[k] + if !slices.Contains(after, u.Module) { + continue // went with its module, which says nothing about the seat + } + lines = append(lines, fmt.Sprintf("and %s on %s now has %s held", u.Module, node, u.Seat)) + } + return lines +} + +func sortedNames[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 6322eec..5db2dcf 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "fmt" + "io" "log" "os" "sort" @@ -62,6 +63,9 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En } func serve(ctx context.Context) error { + // The one process whose log is read over time, so the one that says each change to a node's + // unmet seat dependencies once (novox/hq ADR 0207). + logUnheldChanges = true open, err := openStores(ctx) if err != nil { return err @@ -360,6 +364,9 @@ func pushCommand(ctx context.Context, args []string) error { if err != nil { return err } + // Each machine's unmet seat dependencies (novox/hq ADR 0207), said after the sends: in full + // for a machine named, as a count for each of many — the full list is `status`'s. + unheld := map[string][]catalogue.Unheld{} sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) { plan, settings, err := planFor(held, open, node) if err != nil { @@ -369,6 +376,7 @@ func pushCommand(ctx context.Context, args []string) error { // healthy modules beside it are still resolved and sent. Reported so it is not silently // dropped — the remedy is to move it, and until then the rest of the node converges. reportUnhostable(node, plan) + unheld[node] = plan.Unheld // The private network is in here with everything else. It used to be composed separately // and prepended, which meant every machine with an address was on it and no machine could // be kept off. It is a module now, so it arrives the way a module does. @@ -402,6 +410,7 @@ func pushCommand(ctx context.Context, args []string) error { } release() fmt.Printf("\n%d node(s) told\n", len(sending)) + reportUnheldPushed(os.Stdout, len(args) == 1, asked, unheld) // And each machine's memberships, as every other send does (ADR 0160): a push is the one most // operators run, and on 2026-10-01 it was the one path that issued none. if err := issueMemberships(ctx, open, server, sending); err != nil { @@ -1022,3 +1031,24 @@ func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body } return digest, nil } + +// reportUnheldPushed says what a push's machines lack of the seats their modules depend on +// (novox/hq ADR 0207): every line for a machine the push named, since that is the machine somebody +// is looking at, and one line per machine otherwise — a list per machine across the mesh is the +// hundred lines that buried the one that mattered. Nothing for a machine that lacks nothing. +func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[string][]catalogue.Unheld) { + for _, node := range asked { + lines := unheld[node] + if len(lines) == 0 { + continue + } + if named { + fmt.Fprintf(w, "\n%s has %d unmet seat dependenc(ies) (novox/hq ADR 0207):\n", node, len(lines)) + for _, u := range lines { + fmt.Fprintf(w, " %s\n", u) + } + continue + } + fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines)) + } +} diff --git a/cmd/mesh-controller/unheld_report_test.go b/cmd/mesh-controller/unheld_report_test.go new file mode 100644 index 0000000..1339eda --- /dev/null +++ b/cmd/mesh-controller/unheld_report_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// An act says what it changed about the node it acted on, and nothing about the rest of the mesh +// (novox/hq ADR 0207): after the seat dependencies shipped, every `push ` and `assign` printed +// every node's unmet dependencies, a hundred lines around the one about the module just assigned. + +func aContainer(name string) catalogue.Manifest { + return catalogue.Manifest{Module: name, Version: "1", + Resources: []map[string]any{{"id": name, "type": "container", "image": name}}} +} + +func TestAnAssignmentSaysOnlyWhatItChangedOnItsOwnNode(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, aContainer("web")) + register(t, open, aContainer("db")) + // anchor already lacks a runtime for db: true, and not this act's to say. + if _, err := open.inventory.Assign(ctx, "anchor", "db"); err != nil { + t.Fatal(err) + } + if _, err := open.inventory.Assign(ctx, "laptop", "db"); err != nil { + t.Fatal(err) + } + said, err := assign(ctx, open, "laptop", "web") + if err != nil { + t.Fatalf("%v\n%s", err, said) + } + if !strings.Contains(said, "web on laptop depends on "+catalogue.ContainerRuntimeSeat) { + t.Errorf("the assignment does not say what the module it assigned depends on:\n%s", said) + } + for _, not := range []string{"db on laptop", "db on anchor", "anchor:"} { + if strings.Contains(said, not) { + t.Errorf("the assignment says %q, which it did not change:\n%s", not, said) + } + } +} + +func TestAnAssignmentThatMeetsADependencySaysSo(t *testing.T) { + shelf := map[string]catalogue.Manifest{ + "web": aContainer("web"), + "docker": {Module: "docker", Claims: []catalogue.Claim{{Name: catalogue.ContainerRuntimeSeat}}, + Resources: []map[string]any{{"id": "d", "type": "container", "image": "dind"}}}, + } + lines := unheldChange(shelf, "laptop", []string{"web"}, []string{"web", "docker"}) + if len(lines) != 1 || !strings.Contains(lines[0], "web on laptop now has "+catalogue.ContainerRuntimeSeat+" held") { + t.Errorf("meeting a dependency said %v", lines) + } + // Taking the dependent off says nothing: the dependency went with its module. + if lines := unheldChange(shelf, "laptop", []string{"web"}, nil); len(lines) != 0 { + t.Errorf("unassigning the dependent said %v", lines) + } +} + +func TestAPushSaysANamedNodesDependenciesAndCountsTheRest(t *testing.T) { + unheld := map[string][]catalogue.Unheld{ + "anchor": {{Node: "anchor", Module: "db", Seat: catalogue.ContainerRuntimeSeat}}, + "laptop": {{Node: "laptop", Module: "web", Seat: catalogue.ContainerRuntimeSeat}, + {Node: "laptop", Module: "sshd", Seat: catalogue.ServiceManagerSeat}}, + } + var named bytes.Buffer + reportUnheldPushed(&named, true, []string{"laptop"}, unheld) + got := named.String() + if !strings.Contains(got, "laptop has 2 unmet") || !strings.Contains(got, "web on laptop") || + !strings.Contains(got, "sshd on laptop") || strings.Contains(got, "anchor") { + t.Errorf("a named push said:\n%s", got) + } + var all bytes.Buffer + reportUnheldPushed(&all, false, []string{"anchor", "laptop", "quiet"}, unheld) + want := "anchor: 1 unmet seat dependenc(ies) — see `status`\nlaptop: 2 unmet seat dependenc(ies) — see `status`\n" + if all.String() != want { + t.Errorf("a push to every node said:\n%s\nwant\n%s", all.String(), want) + } +} + +func TestOnlyTheServingControllerLogsEachChange(t *testing.T) { + read := func(f func()) string { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + f() + _ = w.Close() + os.Stderr = old + var b bytes.Buffer + _, _ = b.ReadFrom(r) + return b.String() + } + u := []catalogue.Unheld{{Node: "n1", Module: "web", Seat: catalogue.ContainerRuntimeSeat}} + if got := read(func() { logUnheld("n1", u) }); got != "" { + t.Errorf("a command logged:\n%s", got) + } + logUnheldChanges = true + defer func() { logUnheldChanges = false }() + if got := read(func() { logUnheld("n1", u) }); !strings.Contains(got, "web on n1") { + t.Errorf("the serving controller did not log a change:\n%s", got) + } + if got := read(func() { logUnheld("n1", u) }); got != "" { + t.Errorf("an unchanged report was logged again:\n%s", got) + } +}