From 10f948e9709aebf6a67eb9e9a9a2e89d079ab8ca Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:34:11 +0200 Subject: [PATCH] A module depends on the node seats that apply its resources (hq ADR 0207) Seed node-package-manager and node-container-runtime. Derive each module's dependencies from its declared service, package and container resources; judge them over the node's whole set, exempting the foundation. Refuse at assign (several modules may go on as one act) and at unassign of the last holder; report at composition in status, behind one switch. --- cmd/mesh-controller/acts.go | 142 +++++++-- cmd/mesh-controller/api.go | 4 +- cmd/mesh-controller/build.go | 5 + cmd/mesh-controller/main.go | 4 +- cmd/mesh-controller/modules.go | 8 +- cmd/mesh-controller/plan.go | 33 +++ cmd/mesh-controller/readable.go | 6 + cmd/mesh-controller/seat_dependencies_test.go | 147 ++++++++++ cmd/mesh-controller/seatverbs.go | 4 +- cmd/mesh-controller/status.go | 32 +- internal/catalogue/resolve.go | 15 + internal/catalogue/seat_dependencies.go | 275 ++++++++++++++++++ internal/catalogue/seat_dependencies_test.go | 243 ++++++++++++++++ internal/catalogue/seats.go | 13 +- internal/catalogue/seats_test.go | 15 +- internal/catalogue/verbs.go | 11 +- 16 files changed, 916 insertions(+), 41 deletions(-) create mode 100644 cmd/mesh-controller/seat_dependencies_test.go create mode 100644 internal/catalogue/seat_dependencies.go create mode 100644 internal/catalogue/seat_dependencies_test.go diff --git a/cmd/mesh-controller/acts.go b/cmd/mesh-controller/acts.go index e51e99c..47480ba 100644 --- a/cmd/mesh-controller/acts.go +++ b/cmd/mesh-controller/acts.go @@ -39,7 +39,10 @@ import ( // // It costs a resolution per machine. Assignment is a person typing a command, and being told which // machines this just blocked is worth more than the milliseconds. -func assign(ctx context.Context, open *stores, node, module string) (string, error) { +func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) { + if len(modules) == 0 { + return "", fmt.Errorf("assign %s names no module", node) + } // Held while it is recorded, so it cannot land between a converge's preview and its flip and // be taken without ever having been previewed (novox/hq ADR 0100). ctx, release, err := holdNodes(ctx, open, []string{node}) @@ -47,6 +50,16 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err return "", err } defer release() + // **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else + // an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose + // resources are applied through a seat nothing on the node holds is refused, because that order + // — 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) + if err != nil { + return "", err + } // **Before the new assignment can unsettle a seat somebody holds only by being alone** // (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the // assignment resolves against a record rather than against a coincidence. @@ -54,65 +67,152 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err if err != nil { return "", err } - fresh, err := open.inventory.Assign(ctx, node, module) - if err != nil { - return "", err + var lines []string + var added []string + for _, module := range modules { + fresh, err := open.inventory.Assign(ctx, node, module) + if err != nil { + return strings.Join(lines, "\n"), err + } + if !fresh { + // Nothing changed, and saying "is assigned" would read as an action. One node runs one + // of each — the module's name is the assignment's identity (novox/hq ADR 0115). + lines = append(lines, fmt.Sprintf( + "%s already runs %s — one node runs one of each (ADR 0115); nothing changed", node, module)) + continue + } + added = append(added, module) + lines = append(lines, fmt.Sprintf("%s is assigned %s", node, module)) } - if !fresh { - // Nothing changed, and saying "is assigned" would read as an action. One node runs one - // of each — the module's name is the assignment's identity (novox/hq ADR 0115). - return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed", - node, module), nil + if len(added) == 0 { + return strings.Join(lines, "\n"), nil } - said := fmt.Sprintf("%s is assigned %s", node, module) + answer := strings.Join(lines, "\n") for _, line := range settled { - said += "\n " + line + answer += "\n " + line + } + for _, line := range said { + answer += "\n but " + 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 // runs a second verb and a second push. Issued here when the module speaks on the bus and has // no credential yet; kept when it has one, so re-assigning rotates nothing. - if line := issueOnAssign(ctx, open, node, module); line != "" { - said += "\n " + line + for _, module := range added { + if line := issueOnAssign(ctx, open, node, module); line != "" { + answer += "\n " + line + } } plan, _, err := planFor(ctx, open, node) if err != nil { // Kept, and still refused. Both halves are the answer, and the rest of the mesh is still // worth reporting: this machine's refusal is rarely the only consequence. - return said + blockedElsewhere(ctx, open, node), err + return answer + blockedElsewhere(ctx, open, node), err } // Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose // capability the machine lacks is on the wrong machine, and the assignment records what a person // meant while this line says it will not run until it moves. The rest of the node still pushes. + isAdded := map[string]bool{} + for _, m := range added { + isAdded[m] = true + } for _, u := range plan.Unhostable { - if u.Module != module { + if !isAdded[u.Module] { continue } for _, c := range u.Missing { - said += "\n but " + catalogue.WrongMachine(u.Module, c, node) + answer += "\n but " + catalogue.WrongMachine(u.Module, c, node) } } - return said + fmt.Sprintf("\n run `push %s` to send it", node) + + return answer + fmt.Sprintf("\n run `push %s` to send it", node) + blockedElsewhere(ctx, open, node), nil } -// unassign takes a module off a node. What it leaves behind is the host's business: a directory +// 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) { + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + return nil, err + } + assigned, err := open.inventory.Assigned(ctx, node) + if err != nil { + return nil, err + } + already := map[string]bool{} + for _, a := range assigned { + already[a] = true + } + var adding []string + for _, m := range modules { + if !already[m] { + adding = append(adding, m) + } + } + return catalogue.AssignRefusal(shelf, node, assigned, adding) +} + +// unassign takes modules off a node. What they leave behind is the host's business: a directory // holding anything the mesh did not put there is kept (novox/hq ADR 0030). // // It reports the rest of the mesh for the same reason assign does, and more sharply: taking a // module off one machine is the ordinary way to stop providing something to another, and nothing // about the command's own output would ever have said so. -func unassign(ctx context.Context, open *stores, node, module string) (string, error) { +// +// **Refused when it takes away the last holder of a seat a module left on the node depends on** +// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several +// modules in one act are judged together, so a holder and its dependents come off in one command. +func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) { + if len(modules) == 0 { + return "", fmt.Errorf("unassign %s names no module", node) + } ctx, release, err := holdNodes(ctx, open, []string{node}) if err != nil { return "", err } defer release() - if err := open.inventory.Unassign(ctx, node, module); err != nil { + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { return "", err } + assigned, err := open.inventory.Assigned(ctx, node) + if err != nil { + return "", err + } + // Every one checked before any is taken off, so a refusal leaves the node as it was. + runs := map[string]bool{} + for _, a := range assigned { + runs[a] = true + } + for _, module := range modules { + if !runs[module] { + return "", fmt.Errorf("%s is not assigned to %s", module, node) + } + } + if err := catalogue.UnassignRefusal(shelf, node, assigned, modules); err != nil { + return "", err + } + for _, module := range modules { + if err := open.inventory.Unassign(ctx, node, module); err != nil { + return "", err + } + } return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so", - node, module, node) + blockedElsewhere(ctx, open, node), nil + node, strings.Join(modules, ", "), node) + blockedElsewhere(ctx, open, node), nil +} + +// splitModules is a surface's one `module` field as the modules it names: several, comma-separated, +// are one act (novox/hq ADR 0207), so the holders that depend on each other go on together from the +// command API and the controller seat's verbs as they do from the command line. +func splitModules(field string) []string { + var out []string + for _, m := range strings.Split(field, ",") { + if m = strings.TrimSpace(m); m != "" { + out = append(out, m) + } + } + return out } // blockedElsewhere is every OTHER machine that cannot be worked out as things now stand. diff --git a/cmd/mesh-controller/api.go b/cmd/mesh-controller/api.go index c3fe583..e89d2d3 100644 --- a/cmd/mesh-controller/api.go +++ b/cmd/mesh-controller/api.go @@ -95,10 +95,10 @@ func commands(who Authenticator) http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { - return assign(ctx, open, in.Node, in.Module) + return assign(ctx, open, in.Node, splitModules(in.Module)...) })) mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { - return unassign(ctx, open, in.Node, in.Module) + return unassign(ctx, open, in.Node, splitModules(in.Module)...) })) // Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`. mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 5ec6bca..3869e42 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -650,6 +650,11 @@ type answers struct { // public name on the machine went dark. The holds were correct; they were recorded only in the // machine's own state file, and the one visible symptom was a count that did not add up. untaken map[string]map[string]int + // unheld is every module on a machine whose resources are applied through a seat nothing on + // that machine holds (novox/hq ADR 0207), with the modules that could hold it. Reported, not + // refused, until the switch — and while there is any, the mesh is not all well: the order the + // machines' modules are built in is the mesh's to keep, and this is where it says it is not kept. + unheld []catalogue.Unheld } // heldBy is every artifact this mesh has built, for a build that may need one as its base. diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 7441b5b..baa66f4 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -179,8 +179,8 @@ func usage() { seat --to / hand a seat to that assignment as one act; never empty in between (ADR 0131) board [--listen ADDR] the same three questions, as a page that holds nothing api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here - assign put a module on a node - unassign take it off + assign ... put modules on a node, judged together (ADR 0207) + unassign ... take them off take preview a module's cutover on an adopted node: what runs beside what it declares; --yes cuts it over as previewed converge [--yes ] [--filter nftables] preview, then make, an adopted node converged diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 4b9fdf3..2042c6a 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -334,8 +334,10 @@ func moduleCommand(ctx context.Context, args []string) error { } func assignCommand(ctx context.Context, verb string, args []string) error { - if len(args) != 2 { - return fmt.Errorf("%s ", verb) + // Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the + // service manager and the package manager — can only go on, or come off, together. + if len(args) < 2 { + return fmt.Errorf("%s […]", verb) } open, err := openStores(ctx) if err != nil { @@ -349,7 +351,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error { if verb == "unassign" { act = unassign } - said, err := act(ctx, open, args[0], args[1]) + said, err := act(ctx, open, args[0], args[1:]...) if said != "" { fmt.Println(said) } diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 95f2b33..4f81b17 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -10,6 +10,7 @@ import ( "os" "sort" "strings" + "sync" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/catalogue" @@ -127,6 +128,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso // a mesh-wide gatherer may pass over — see notResolvable. return catalogue.Resolution{}, nil, notResolvable{err} } + logUnheld(nodeName, resolved.Unheld) // The credential for each thing this node takes from elsewhere. Made once and kept, so the // password a provider is told to create is the one its consumer was given — and sealed to @@ -1325,3 +1327,34 @@ func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.C } return "" } + +// 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. +var ( + unheldLogged = map[string]string{} + unheldLoggedMu sync.Mutex +) + +// logUnheld logs a node's unmet seat dependencies when they differ from what was last logged for +// it, including when they become none. +func logUnheld(node string, unheld []catalogue.Unheld) { + lines := make([]string, 0, len(unheld)) + for _, u := range unheld { + lines = append(lines, u.String()) + } + now := strings.Join(lines, "\n") + unheldLoggedMu.Lock() + before, seen := unheldLogged[node] + unheldLogged[node] = now + unheldLoggedMu.Unlock() + if (seen && before == now) || (!seen && now == "") { + return + } + if now == "" { + fmt.Fprintf(os.Stderr, "%s: every seat its modules depend on is held (novox/hq ADR 0207)\n", node) + return + } + 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 ")) +} diff --git a/cmd/mesh-controller/readable.go b/cmd/mesh-controller/readable.go index 9894092..6049fd0 100644 --- a/cmd/mesh-controller/readable.go +++ b/cmd/mesh-controller/readable.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" "sort" "time" @@ -73,6 +74,10 @@ type meshStatus struct { // alone. A document without this called a machine well while a predecessor's chain refused // what the mesh declared open. Filtered []machineFiltered `json:"filtered,omitempty"` + // Unheld is every module on a machine whose resources are applied through a seat nothing on + // that machine holds, with the modules that could hold it (novox/hq ADR 0207). Absent when every + // dependency is met. Reported, not refused, until the switch. + Unheld []catalogue.Unheld `json:"unheld,omitempty"` } // machineFiltered is one rule set on a converged machine that the mesh did not write and that @@ -204,6 +209,7 @@ func statusAsJSON(asked answers) ([]byte, error) { out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses}) } } + out.Unheld = asked.unheld for name := range asked.refused { out.Unresolved = append(out.Unresolved, machineUnresolved{ Node: name, Problem: asked.refused[name]}) diff --git a/cmd/mesh-controller/seat_dependencies_test.go b/cmd/mesh-controller/seat_dependencies_test.go new file mode 100644 index 0000000..27f95c3 --- /dev/null +++ b/cmd/mesh-controller/seat_dependencies_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Defends novox/hq ADR 0207 at the controller's acts: `assign` refuses a module whose resources a +// seat nothing on the node holds applies, `unassign` refuses taking the last holder from under its +// dependents, and `status` reports what composition does not yet refuse. + +func serviceManagerHolder() catalogue.Manifest { + return catalogue.Manifest{Module: "systemd", Version: "1", + Claims: []catalogue.Claim{{Name: catalogue.ServiceManagerSeat, Scope: catalogue.ScopeNode, + Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}}}, + Resources: []map[string]any{{"id": "systemd", "type": "package", "package": "systemd"}}} +} + +func packageManagerHolder() catalogue.Manifest { + return catalogue.Manifest{Module: "pacman", Version: "1", + Claims: []catalogue.Claim{{Name: catalogue.PackageManagerSeat, Scope: catalogue.ScopeNode}}, + Resources: []map[string]any{{"id": "refresh", "type": "service", "unit": "pacman-refresh.timer"}}} +} + +func aDaemon() catalogue.Manifest { + return catalogue.Manifest{Module: "sshd", Version: "1", + Resources: []map[string]any{{"id": "sshd", "type": "service", "unit": "sshd.service"}}} +} + +func TestAnAssignmentWithoutItsHolderIsRefusedAndNotKept(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, serviceManagerHolder()) + register(t, open, packageManagerHolder()) + register(t, open, aDaemon()) + + _, err := assign(ctx, open, "laptop", "sshd") + if err == nil { + t.Fatal("sshd went onto a machine nothing holds the service manager of") + } + for _, want := range []string{catalogue.ServiceManagerSeat, "systemd", "ADR 0207"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q:\n%v", want, err) + } + } + assigned, err := open.inventory.Assigned(ctx, "laptop") + if err != nil { + t.Fatal(err) + } + if contains(assigned, "sshd") { + t.Fatalf("a refused assignment was kept: %v", assigned) + } + + // The holders depend on each other, so neither goes on alone — and both go on in one act. + if _, err := assign(ctx, open, "laptop", "systemd"); err == nil { + t.Fatal("systemd went on alone though its package needs a package manager") + } + if said, err := assign(ctx, open, "laptop", "systemd", "pacman"); err != nil { + t.Fatalf("the two holders assigned together were refused: %v\n%s", err, said) + } + if said, err := assign(ctx, open, "laptop", "sshd"); err != nil { + t.Fatalf("sshd beside its holder was refused: %v\n%s", err, said) + } +} + +func TestTheControllerSeatsAssignTakesSeveralModulesAsOneAct(t *testing.T) { + argv, err := argvFor("assign", map[string]any{"node": "laptop", "module": "systemd, pacman"}) + if err != nil { + t.Fatal(err) + } + if strings.Join(argv, " ") != "assign laptop systemd pacman" { + t.Errorf("the seat's assign became %v", argv) + } +} + +func TestUnassigningTheLastHolderUnderItsDependentsIsRefused(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, serviceManagerHolder()) + register(t, open, packageManagerHolder()) + register(t, open, aDaemon()) + if _, err := assign(ctx, open, "laptop", "systemd", "pacman", "sshd"); err != nil { + t.Fatal(err) + } + + _, err := unassign(ctx, open, "laptop", "systemd") + if err == nil { + t.Fatal("the service manager came off a machine still running services") + } + for _, want := range []string{catalogue.ServiceManagerSeat, "sshd", "pacman"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q:\n%v", want, err) + } + } + assigned, _ := open.inventory.Assigned(ctx, "laptop") + if !contains(assigned, "systemd") { + t.Fatalf("a refused unassignment took the module off anyway: %v", assigned) + } + if _, err := unassign(ctx, open, "laptop", "sshd"); err != nil { + t.Fatalf("a dependent could not come off: %v", err) + } +} + +func TestStatusReportsAnUnheldDependencyWithoutRefusingTheMachine(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, serviceManagerHolder()) + register(t, open, aDaemon()) + // Assigned straight into the store: a machine whose modules predate the rule, which is every + // machine on the day it ships. + if _, err := open.inventory.Assign(ctx, "laptop", "sshd"); err != nil { + t.Fatal(err) + } + + asked, err := theThreeQuestions(ctx, open) + if err != nil { + t.Fatal(err) + } + if _, refused := asked.refused["laptop"]; refused { + t.Fatalf("an unmet dependency refused the machine before the switch: %s", asked.refused["laptop"]) + } + if asked.well() { + t.Error("a mesh with an unheld dependency reads as all well") + } + got := printed(t, func() error { return printStatus(asked) }) + for _, want := range []string{"unheld", "laptop", "sshd", catalogue.ServiceManagerSeat, "systemd"} { + if !strings.Contains(got, want) { + t.Errorf("status does not say %q:\n%s", want, got) + } + } + body, err := statusAsJSON(asked) + if err != nil { + t.Fatal(err) + } + var doc struct { + Unheld []catalogue.Unheld `json:"unheld"` + } + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatal(err) + } + if len(doc.Unheld) != 1 || doc.Unheld[0].Module != "sshd" || doc.Unheld[0].Seat != catalogue.ServiceManagerSeat { + t.Errorf("the document's unheld is %+v", doc.Unheld) + } +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index e16601a..9d2bb65 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -113,7 +113,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) { if err := need("node", "module"); err != nil { return nil, err } - return []string{verb, str("node"), str("module")}, nil + // Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of + // the seats that apply resources depend on each other and go on together. + return append([]string{verb, str("node")}, splitModules(str("module"))...), nil case "pin": if err := need("node", "provision", "from", "module"); err != nil { return nil, err diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index 1c8cc97..9756347 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -282,6 +282,22 @@ func printStatus(asked answers) error { fmt.Printf("\n `take ` compares what runs against what it declares, and runs it\n\n") } + if len(asked.unheld) > 0 { + // **Reported, and not refused yet** (novox/hq ADR 0207 §4). Each machine still resolves and + // is sent what it would be; this says which of its modules depend on a seat nothing there + // holds, until every machine has its holders and the switch makes it a refusal. + fmt.Printf("%d module dependenc(ies) on a seat nothing on the machine holds (unheld, ADR 0207):\n", + len(asked.unheld)) + for _, u := range asked.unheld { + holders := "no module in the catalogue claims it yet" + if len(u.Holders) > 0 { + holders = "could be held by " + strings.Join(u.Holders, ", ") + } + fmt.Printf(" %-12s %-24s %-24s %s\n", u.Node, u.Module, u.Seat, holders) + } + fmt.Printf("\n `assign ` meets it; reported until every machine has its holders, then refused\n\n") + } + if adopted := adoptedNodes(nodes); len(adopted) > 0 { // Said, because nothing forces the flip: a node left adopted is visible here rather than // read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well". @@ -386,6 +402,20 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) { if err != nil { return answers{}, err } + // And which machines run a module whose resources a seat nothing there holds applies (novox/hq + // ADR 0207). Each machine resolved again rather than threaded through whoResolves, whose answer + // the private network is built from and should say nothing else; a machine that does not + // resolve is already in refused, and is passed over here. + for _, n := range out.nodes { + plan, _, err := planFor(ctx, open, n.Name) + if err != nil { + if unresolvable(err) { + continue + } + return answers{}, err + } + out.unheld = append(out.unheld, plan.Unheld...) + } out.plans, err = inv.RecentPlans(ctx, 5) if err != nil { return answers{}, err @@ -496,7 +526,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven func (a answers) well() bool { return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 && len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 && - len(a.filtered) == 0 + len(a.filtered) == 0 && len(a.unheld) == 0 } // hostSplit is which machines report which host version, for every version more than one machine diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index a33f761..e573a5d 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -147,6 +147,10 @@ type Resolution struct { // dropped nor fatal to the rest. A module that is *required* by something running here is a // different case — that set is incoherent and is refused (see checkCapabilities). Unhostable []Unhostable + // Unheld is every dependency of this node's modules on a seat nothing here holds (novox/hq ADR + // 0207) — reported rather than refused while enforceSeatDependencies is off, so a node short of a + // holder still converges and `status` says what it is short of. + Unheld []Unheld } // Unhostable is one directly-assigned module the machine cannot run. @@ -628,6 +632,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world problems = append(problems, checkResources(resolution.Modules)...) resolution.Claims = claims + // Judged over the closure — what this node will actually run — so a holder pulled in by a + // requirement counts, and the holders' mutual dependence resolves (novox/hq ADR 0207 §3). + // Reported until the switch; refused after it, though never in the first pass, whose refusals + // make a machine vanish from the network rather than report anything. + resolution.Unheld = UnheldDependencies(catalogue, node.Name, resolution.Modules, nil) + if enforceSeatDependencies && !world.Unchecked { + for _, u := range resolution.Unheld { + problems = append(problems, u.String()) + } + } + if len(problems) > 0 { sort.Strings(problems) return Resolution{}, &Refusal{Problems: problems} diff --git a/internal/catalogue/seat_dependencies.go b/internal/catalogue/seat_dependencies.go new file mode 100644 index 0000000..44f2f7f --- /dev/null +++ b/internal/catalogue/seat_dependencies.go @@ -0,0 +1,275 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// A module depends on the node seats that apply its resources (novox/hq ADR 0207). +// +// Some of what a module declares is applied through software on the machine that is itself a +// module: a service through the service manager, a package through the package manager, a container +// through the container runtime. A *capability* only says that software is installed; it does not +// say that a module of the mesh holds the role and answers for it. So the dependency is derived from +// the resources — never stated in a manifest, because a module that adds a service and forgets a +// field would pass — and is met when some module assigned to the same node holds the seat. + +// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation, +// the seed and the messages all turn on these strings. +const ( + ServiceManagerSeat = "node-service-manager" + PackageManagerSeat = "node-package-manager" + ContainerRuntimeSeat = "node-container-runtime" +) + +// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207 +// names them and no more. A process is supervised by the host itself, a file, a directory, an +// archive, a user or an action is the host's own act, and a module's other kinds reach the machine +// without a role in between — adding one here is a decision, not a refinement. +var appliedThrough = map[string]string{ + "service": ServiceManagerSeat, + "package": PackageManagerSeat, + "container": ContainerRuntimeSeat, +} + +// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at +// composition is *reported* — in the resolution, in `status`, once in the log — and the node still +// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none, +// which is when the three holders are assigned to every node: switching it before then would stop +// every machine lacking one from being sent anything at all. +// +// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it. +var enforceSeatDependencies = false + +// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5): +// the host and the private network. Registered as modules so they can be assigned, but what they +// declare is the installation's, not a module's, so it is never judged. The third piece, the +// bootstrap container runtime, is not a module at all: its package and service are in the genesis +// bundle the host applies itself, and never pass through a resolution here. +// +// The private network's module is overlay.Name, written out because the overlay package composes +// on top of this one; its resources are computed, which exempts it by the rule below as well. +var foundationModules = map[string]bool{ + "mesh-host": true, + "mesh-wireguard": true, +} + +// isFoundation is whether a module's declarations are the foundation's rather than its own. A +// module whose resources are computed is the mesh's by construction — the private network's peer +// list and its tools are the controller's, written per node — so it counts whatever its name. +func isFoundation(m Manifest) bool { + return foundationModules[m.Module] || m.Computed != "" +} + +// DependsOn is every seat a module needs held on its node, derived from the resource types it +// declares itself (novox/hq ADR 0207 §2), sorted. +// +// **The module's own `resources` only.** What the controller composes around a module — its +// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the +// module is assigned, and depending on it would make the module answer for the mesh's choices. +func DependsOn(m Manifest) []string { + if isFoundation(m) { + return nil + } + seen := map[string]bool{} + for _, r := range m.Resources { + if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied { + seen[seat] = true + } + } + out := make([]string, 0, len(seen)) + for s := range seen { + out = append(out, s) + } + sort.Strings(out) + return out +} + +// claimsSeat is whether a module claims a node seat, by its current name or one it used to have +// (ADR 0122), so a rename leaves the dependency met. +func claimsSeat(m Manifest, seat string) bool { + for _, c := range m.Claims { + if c.At() != ScopeNode { + continue + } + name := c.Name + if s, known := SeatNamed(name); known { + name = s.Name + } + if name == seat { + return true + } + } + return false +} + +// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a +// refusal names as the remedy. +func PossibleHolders(catalogue map[string]Manifest, seat string) []string { + var out []string + for name, m := range catalogue { + if claimsSeat(m, seat) { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +// Unheld is one dependency of one module on a node that nothing on that node holds. +type Unheld struct { + Node string `json:"node"` + Module string `json:"module"` + Seat string `json:"seat"` + // Holders are the modules in the catalogue that could hold the seat: assigning one meets it. + Holders []string `json:"holders"` +} + +// String is the line a refusal and a report both say, so the two never drift. +func (u Unheld) String() string { + remedy := "and no module in the catalogue claims it yet" + if len(u.Holders) > 0 { + remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ") + } + return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s", + u.Module, u.Node, u.Seat, u.Node, remedy) +} + +// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set +// leaves unmet (novox/hq ADR 0207 §3). +// +// **Judged over the whole set, never one module at a time.** The holders depend on each other: +// the service manager's own package needs the package manager, and the package manager's timer +// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the +// two assigned together meet each other. A module holding a seat it depends on meets its own +// dependency. `judged` nil judges every module of the set. +func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld { + held := map[string]bool{} + for _, m := range set { + for seat := range seatsApplying() { + if claimsSeat(m, seat) { + held[seat] = true + } + } + } + var out []Unheld + for _, m := range set { + if judged != nil && !judged[m.Module] { + continue + } + for _, seat := range DependsOn(m) { + if held[seat] { + continue + } + out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat, + Holders: PossibleHolders(catalogue, seat)}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Module != out[j].Module { + return out[i].Module < out[j].Module + } + return out[i].Seat < out[j].Seat + }) + return out +} + +func seatsApplying() map[string]bool { + out := map[string]bool{} + for _, s := range appliedThrough { + out[s] = true + } + return out +} + +// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not +// know contributes nothing, as it does to a resolution. +func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest { + var out []Manifest + seen := map[string]bool{} + for _, n := range names { + if m, known := catalogue[n]; known && !seen[n] { + seen[n] = true + out = append(out, m) + } + } + return out +} + +// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or +// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones +// included, leave unmet. +// +// **Only the new modules are judged.** A node already short of a holder is reported by `status`; +// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too. +// +// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the +// module that would meet it; with none registered there is no remedy to name, and refusing would +// stop every assignment of that kind until a module that does not exist yet is written. The answer +// still says it, and `status` reports it, until the switch (enforceSeatDependencies) makes the mesh +// refuse what it cannot meet. The first return is those lines. +func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) { + set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...)) + judged := map[string]bool{} + for _, a := range adding { + judged[a] = true + } + var refused, said []string + for _, u := range UnheldDependencies(catalogue, node, set, judged) { + if len(u.Holders) == 0 && !enforceSeatDependencies { + said = append(said, u.String()) + continue + } + refused = append(refused, u.String()) + } + if len(refused) > 0 { + return said, &Refusal{Problems: append(refused, + fmt.Sprintf("holders that depend on each other are assigned together: `assign %s …`", node))} + } + return said, nil +} + +// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing +// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while +// a module left there depends on it. Names the dependents, because they are what must go first — +// or the holder's replacement come. +func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error { + gone := map[string]bool{} + for _, r := range removing { + gone[r] = true + } + var left []string + for _, a := range assigned { + if !gone[a] { + left = append(left, a) + } + } + before := map[string]bool{} + for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) { + before[u.Module+"\x00"+u.Seat] = true + } + dependents := map[string][]string{} + for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) { + if before[u.Module+"\x00"+u.Seat] { + continue // unmet already; not this removal's doing + } + dependents[u.Seat] = append(dependents[u.Seat], u.Module) + } + if len(dependents) == 0 { + return nil + } + seats := make([]string, 0, len(dependents)) + for s := range dependents { + seats = append(seats, s) + } + sort.Strings(seats) + var problems []string + for _, s := range seats { + problems = append(problems, fmt.Sprintf( + "%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+ + "or assign another holder first", strings.Join(removing, ", "), s, node, + strings.Join(dependents[s], ", "))) + } + return &Refusal{Problems: problems} +} diff --git a/internal/catalogue/seat_dependencies_test.go b/internal/catalogue/seat_dependencies_test.go new file mode 100644 index 0000000..0703ee9 --- /dev/null +++ b/internal/catalogue/seat_dependencies_test.go @@ -0,0 +1,243 @@ +package catalogue + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +// Defends novox/hq ADR 0207: a module depends on the node seats that apply its resources. + +func res(kind, id string) map[string]any { + r := map[string]any{"id": id, "type": kind} + switch kind { + case "service": + r["unit"] = id + ".service" + case "package": + r["package"] = id + case "container": + r["image"] = id + case "file": + r["path"] = "/etc/" + id + } + return r +} + +func withResources(m Manifest, rs ...map[string]any) Manifest { + m.Resources = rs + return m +} + +// The three holders as to-be 42 names them, each declaring what it really does: systemd's own +// package needs the package manager, pacman's timer needs the service manager, docker's package and +// service need both. +func coreThree() []Manifest { + return []Manifest{ + withResources(mod("systemd", nil, nil, nil, Claim{Name: ServiceManagerSeat}), res("package", "systemd")), + withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}), res("service", "pacman-refresh")), + withResources(mod("docker", nil, nil, nil, Claim{Name: ContainerRuntimeSeat}), + res("package", "docker"), res("service", "docker")), + } +} + +func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *testing.T) { + cases := map[string][]string{ + "service": {ServiceManagerSeat}, + "package": {PackageManagerSeat}, + "container": {ContainerRuntimeSeat}, + // The host's own acts, or the mesh's: nothing in between holds a role for them. + "file": nil, "directory": nil, "process": nil, "archive": nil, "user": nil, + "action": nil, "network": nil, "access": nil, + } + for kind, want := range cases { + got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x"))) + if len(got) == 0 { + got = nil + } + if !reflect.DeepEqual(got, want) { + t.Errorf("a %s resource depends on %v, want %v", kind, got, want) + } + } + all := DependsOn(withResources(mod("m", nil, nil, nil), + res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d"))) + if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) { + t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want) + } +} + +func TestTheSeatsThatApplyResourcesAreTheMeshsOwnAtNodeScope(t *testing.T) { + for _, name := range []string{ServiceManagerSeat, PackageManagerSeat, ContainerRuntimeSeat} { + s, ok := SeatNamed(name) + if !ok { + t.Fatalf("%s is not in the mesh's set", name) + } + if s.Scope != ScopeNode { + t.Errorf("%s is held per %s, want per node", name, s.Scope) + } + } + // No verbs yet for either new seat: ADR 0207 seeds the package manager without a protocol, and + // the runtime's verbs wait for ADR 0166's acceptance. + for _, name := range []string{PackageManagerSeat, ContainerRuntimeSeat} { + if s, _ := SeatNamed(name); len(s.Serves)+len(s.Accepts)+len(s.Emits) > 0 { + t.Errorf("%s carries a protocol; ADR 0207 seeds it with none", name) + } + } +} + +func TestANodeWhoseAssignmentsHoldTheSeatsResolvesWithNothingUnheld(t *testing.T) { + web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer")) + cat := shelf(append(coreThree(), web)...) + got, err := Resolve(cat, []string{"systemd", "pacman", "docker", "web"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if len(got.Unheld) != 0 { + t.Errorf("a node holding all three seats reports %v", got.Unheld) + } + if _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman", "docker"}, []string{"web"}); err != nil { + t.Errorf("assigning beside the three holders was refused: %v", err) + } +} + +func TestAnAssignmentMissingAHolderIsRefusedNamingTheSeatAndItsPossibleHolders(t *testing.T) { + web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer")) + cat := shelf(append(coreThree(), web)...) + _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman"}, []string{"web"}) + var refusal *Refusal + if !errors.As(err, &refusal) { + t.Fatalf("web assigned to a node without a container runtime was not refused: %v", err) + } + msg := err.Error() + for _, want := range []string{"web on workstation depends on " + ContainerRuntimeSeat, "docker", "ADR 0207"} { + if !strings.Contains(msg, want) { + t.Errorf("the refusal does not say %q:\n%s", want, msg) + } + } + // What the node does hold is not named as missing. + if strings.Contains(msg, "depends on "+ServiceManagerSeat) { + t.Errorf("the refusal names a seat systemd already holds:\n%s", msg) + } +} + +func TestADependencyNoCatalogueModuleCanMeetIsSaidNotRefusedUntilTheSwitch(t *testing.T) { + // No runtime module in the catalogue: refusing would stop every container's assignment until one + // is written, with no remedy to name. + web := withResources(mod("web", nil, nil, nil), res("container", "web")) + cat := shelf(web) + said, err := AssignRefusal(cat, "workstation", nil, []string{"web"}) + if err != nil { + t.Fatalf("a dependency nothing could meet was refused: %v", err) + } + if len(said) != 1 || !strings.Contains(said[0], "no module in the catalogue claims it yet") { + t.Errorf("the assignment does not say what it depends on: %v", said) + } + + enforceSeatDependencies = true + defer func() { enforceSeatDependencies = false }() + if _, err := AssignRefusal(cat, "workstation", nil, []string{"web"}); err == nil { + t.Error("with the switch on, a dependency nothing could meet was not refused") + } +} + +func TestTheHoldersMutualDependenceResolvesWhenAssignedTogether(t *testing.T) { + cat := shelf(coreThree()...) + // Alone, each needs the other. + if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd"}); err == nil || + !strings.Contains(err.Error(), "pacman") { + t.Errorf("systemd alone was not refused naming pacman: %v", err) + } + if _, err := AssignRefusal(cat, "workstation", nil, []string{"pacman"}); err == nil || + !strings.Contains(err.Error(), "systemd") { + t.Errorf("pacman alone was not refused naming systemd: %v", err) + } + // Together, in one act, they meet each other — and docker meets its own seat. + if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd", "pacman", "docker"}); err != nil { + t.Errorf("the three holders assigned together were refused: %v", err) + } + got, err := Resolve(cat, []string{"systemd", "pacman"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if len(got.Unheld) != 0 { + t.Errorf("systemd and pacman together report %v", got.Unheld) + } +} + +func TestStatusIsToldOfAnUnmetDependencyAndTheNodeStillResolves(t *testing.T) { + web := withResources(mod("web", nil, nil, nil), res("container", "web")) + cat := shelf(append(coreThree(), web)...) + got, err := Resolve(cat, []string{"web"}, workstation(), World{}) + if err != nil { + t.Fatalf("an unmet dependency refused the node before the switch: %v", err) + } + want := []Unheld{{Node: "workstation", Module: "web", Seat: ContainerRuntimeSeat, Holders: []string{"docker"}}} + if !reflect.DeepEqual(got.Unheld, want) { + t.Errorf("reported %+v, want %+v", got.Unheld, want) + } +} + +func TestWithTheSwitchFlippedAnUnmetDependencyRefusesTheNode(t *testing.T) { + enforceSeatDependencies = true + defer func() { enforceSeatDependencies = false }() + web := withResources(mod("web", nil, nil, nil), res("container", "web")) + cat := shelf(append(coreThree(), web)...) + _, err := Resolve(cat, []string{"web"}, workstation(), World{}) + if err == nil || !strings.Contains(err.Error(), ContainerRuntimeSeat) || !strings.Contains(err.Error(), "docker") { + t.Fatalf("with the switch on, an unmet dependency gave %v", err) + } + // Never in the first pass, whose refusals take a machine off the network instead. + if _, err := Resolve(cat, []string{"web"}, workstation(), World{Unchecked: true}); err != nil { + t.Errorf("the first pass refused an unmet dependency: %v", err) + } +} + +func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) { + // The private network, as the controller computes it: its tools' package and its service are + // the mesh's, written per node, and so are never a module's dependency. + network := withResources(mod("mesh-wireguard", []string{"private-network"}, nil, nil), + res("package", "wireguard-tools"), res("service", "overlay-up")) + network.Computed = "mesh-wireguard" + // The host, whatever it declares. + host := withResources(mod("mesh-host", nil, nil, nil), res("file", "launcher"), res("service", "nox-mesh-host")) + cat := shelf(append(coreThree(), network, host)...) + + for _, m := range []Manifest{network, host} { + if d := DependsOn(m); len(d) != 0 { + t.Errorf("%s, the foundation's, depends on %v", m.Module, d) + } + } + got, err := Resolve(cat, []string{"mesh-wireguard", "mesh-host"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + if len(got.Unheld) != 0 { + t.Errorf("the foundation on a node with no holders reports %v", got.Unheld) + } + if _, err := AssignRefusal(cat, "workstation", nil, []string{"mesh-wireguard", "mesh-host"}); err != nil { + t.Errorf("assigning the foundation was refused: %v", err) + } +} + +func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) { + sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd")) + cat := shelf(append(coreThree(), sshd)...) + on := []string{"systemd", "pacman", "docker", "sshd"} + + err := UnassignRefusal(cat, "workstation", on, []string{"systemd"}) + if err == nil { + t.Fatal("the last service manager came off a node still running services") + } + for _, want := range []string{ServiceManagerSeat, "sshd", "pacman", "docker"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q:\n%v", want, err) + } + } + // A dependent comes off freely, and the holders with everything depending on them in one act. + if err := UnassignRefusal(cat, "workstation", on, []string{"sshd"}); err != nil { + t.Errorf("a dependent's unassignment was refused: %v", err) + } + if err := UnassignRefusal(cat, "workstation", on, on); err != nil { + t.Errorf("unassigning everything together was refused: %v", err) + } +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index cec63fc..b7b5e68 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -148,8 +148,19 @@ var defaultSeats = []Seat{ // 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: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177", + {Name: ServiceManagerSeat, Scope: 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: PackageManagerSeat, Scope: 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: ContainerRuntimeSeat, Scope: 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 diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 31d8baa..91095d3 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -18,7 +18,9 @@ import ( // vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq // and a row in to-be 26, not a new number here. func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { - record := regexp.MustCompile(`^novox/hq ADR \d{4}$`) + // A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined + // it and the one that seeded it. + record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`) seen := map[string]bool{} delivered := map[string]string{} for _, s := range Seats() { @@ -44,11 +46,12 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Nineteen since node-environment and node-login-shell (novox/hq ADR 0203, ADR 0204), after - // node-build-agent made seventeen (ADR 0190) — eighteen once the retired mesh-build-machine row - // goes, when no registered manifest claims it any more. - if len(Seats()) != 19 { - t.Errorf("the mesh defines %d seats rather than 19; the set is closed, so a change here is "+ + // Twenty-one since node-package-manager and node-container-runtime (novox/hq ADR 0207), after + // node-environment and node-login-shell made nineteen (ADR 0203, ADR 0204) and node-build-agent + // seventeen (ADR 0190) — twenty once the retired mesh-build-machine row goes, when no registered + // manifest claims it any more. + if len(Seats()) != 21 { + t.Errorf("the mesh defines %d seats rather than 21; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index f8f52bd..f4a1352 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -102,10 +102,13 @@ var ControllerVerbs = []Verb{ }, nil)}, {Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.", Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, - {Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.", - Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})}, - {Name: "unassign", Description: "Take a module off a machine.", - Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})}, + {Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " + + "or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).", + Input: schema(map[string]string{"node": "the machine's name", + "module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})}, + {Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.", + Input: schema(map[string]string{"node": "the machine's name", + "module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})}, {Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " + "it runs on, both. Asked for when more than one could answer; the refusal lists them.", Input: schema(map[string]string{