From 10f948e9709aebf6a67eb9e9a9a2e89d079ab8ca Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:34:11 +0200 Subject: [PATCH 1/3] 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{ From d69e19103c0a3e0878c9c4ee2ef45a4378c8c661 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:38:53 +0200 Subject: [PATCH 2/3] The graphical session's seats, a display's machine reach, and the session's slots (hq ADR 0208) Seed the eleven node seats with the verbs they start with. A provision may have the machine's reach: a requirement for it resolves only to a provider in the node's own set, is never pulled in, and is refused naming who could. A shell contribution's for gains xinitrc and xresources, placed only by the holder of node-display-server. --- internal/catalogue/environment_into.go | 55 ++++- internal/catalogue/graphical_session.go | 105 +++++++++ internal/catalogue/graphical_session_test.go | 213 +++++++++++++++++++ internal/catalogue/manifest.go | 32 ++- internal/catalogue/resolve.go | 73 +++++++ internal/catalogue/seats.go | 6 +- internal/catalogue/seats_test.go | 12 +- 7 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 internal/catalogue/graphical_session.go create mode 100644 internal/catalogue/graphical_session_test.go diff --git a/internal/catalogue/environment_into.go b/internal/catalogue/environment_into.go index f5f0484..13216da 100644 --- a/internal/catalogue/environment_into.go +++ b/internal/catalogue/environment_into.go @@ -68,8 +68,27 @@ type ShellCode struct { var ( knownShells = []string{"zsh", "bash", "fish"} knownSlots = []string{"first", "normal", "last"} + // sessionFiles are the two files of the graphical session's start that read no directory, so a + // contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX + // code the session's start runs, `xresources` X resources merged at its start. Placed by the + // display server's holder, as a shell's slots are placed by the login shell's. + sessionFiles = []string{"xinitrc", "xresources"} ) +// contributionTargets is every name a contribution's `for` may take. +func contributionTargets() []string { + return append(append([]string(nil), knownShells...), sessionFiles...) +} + +// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204, +// ADR 0208 §4). +func placerOf(target string) string { + if oneOf(sessionFiles, target) { + return DisplayServerSeat + } + return LoginShellSeat +} + // The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3). const ( EnvironmentPOSIX = "posix" @@ -170,10 +189,10 @@ func literalProblem(v string) string { func (m Manifest) shellProblems() []string { var problems []string for i, c := range m.Shell { - if !oneOf(knownShells, c.For) { + if !oneOf(contributionTargets(), c.For) { problems = append(problems, fmt.Sprintf( - "%s's shell code %d is for %q; the shells are %s", m.Module, i+1, c.For, - strings.Join(knownShells, ", "))) + "%s's shell code %d is for %q; the shells are %s, and the session's files %s", + m.Module, i+1, c.For, strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "))) } if !oneOf(knownSlots, c.Slot) { problems = append(problems, fmt.Sprintf( @@ -237,20 +256,36 @@ func placeholderProblems(m Manifest, r map[string]any) []string { "written by that seat's holder alone (novox/hq ADR 0203)", m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat)) } + // Each placeholder judged by its own target: a shell's code is the login shell's holder's to + // place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of + // one placing the other's would be a second writer of a file there is one of. + refusedFor := map[string]bool{} for _, c := range code { - shell, slot, two := strings.Cut(c[1], ":") - if !two || !oneOf(knownShells, shell) || !oneOf(knownSlots, slot) { + target, slot, two := strings.Cut(c[1], ":") + if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s; shell code is ${shell::}, the shell one of "+ - "%s and the slot one of %s", m.Module, r["id"], c[0], - strings.Join(knownShells, ", "), strings.Join(knownSlots, ", "))) + "%s or the session's file one of %s, and the slot one of %s", m.Module, r["id"], c[0], + strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), + strings.Join(knownSlots, ", "))) + continue + } + seat := placerOf(target) + if m.ClaimsSeat(seat) || refusedFor[seat] { + continue + } + refusedFor[seat] = true + if seat == DisplayServerSeat { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+ + "the display server's holder alone (novox/hq ADR 0208)", + m.Module, r["id"], c[0], m.Module, seat, target)) + continue } - } - if len(code) > 0 && !m.ClaimsSeat(LoginShellSeat) { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s and %s does not claim %s; every module's shell code is "+ "placed by the login shell's holder alone (novox/hq ADR 0204)", - m.Module, r["id"], code[0][0], m.Module, LoginShellSeat)) + m.Module, r["id"], c[0], m.Module, seat)) } } return problems diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go new file mode 100644 index 0000000..6271fee --- /dev/null +++ b/internal/catalogue/graphical_session.go @@ -0,0 +1,105 @@ +package catalogue + +// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's +// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for +// a role rather than each inventing one — and a machine running two of one role is refused at +// assignment instead of found by two bars on one screen. +const ( + LoginManagerSeat = "node-login-manager" + DisplayServerSeat = "node-display-server" + DisplaySessionSeat = "node-display-session" + TerminalEmulatorSeat = "node-terminal-emulator" + LauncherSeat = "node-launcher" + NotifierSeat = "node-notifier" + LockScreenSeat = "node-lock-screen" + ClipboardSeat = "node-clipboard" + BarSeat = "node-bar" + CompositorSeat = "node-compositor" + SecretServiceSeat = "node-secret-service" +) + +// 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, the compositor and the secret +// service are roles a second holder competes for, and nothing has needed to ask them anything. +func graphicalSessionSeats() []Seat { + const decided = "novox/hq ADR 0208" + return []Seat{ + {Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []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: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []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")}, + }}, + {Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided, Serves: []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: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []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: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []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)"}, + }}}, + }}, + {Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []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: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ + {Name: "lock", Description: "Lock the operator's session now.", + Input: schema(map[string]string{}, nil)}, + }}, + {Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []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"})}, + }}, + {Name: BarSeat, Scope: ScopeNode, Decision: decided}, + {Name: CompositorSeat, Scope: ScopeNode, Decision: decided}, + {Name: SecretServiceSeat, Scope: 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 +} diff --git a/internal/catalogue/graphical_session_test.go b/internal/catalogue/graphical_session_test.go new file mode 100644 index 0000000..d349e1d --- /dev/null +++ b/internal/catalogue/graphical_session_test.go @@ -0,0 +1,213 @@ +package catalogue + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// Defends novox/hq ADR 0208: the graphical session is one module per piece, on the mesh's seats. + +// §2: the eleven roles are the mesh's own node seats, each with the verbs it starts with. +func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) { + want := map[string][]string{ + LoginManagerSeat: {"sessions"}, + DisplayServerSeat: {"displays", "layout"}, + DisplaySessionSeat: {"reload", "workspaces", "windows"}, + TerminalEmulatorSeat: {"open"}, + LauncherSeat: {"menu"}, + NotifierSeat: {"send", "history"}, + LockScreenSeat: {"lock"}, + ClipboardSeat: {"history", "copy"}, + BarSeat: nil, + CompositorSeat: nil, + SecretServiceSeat: nil, + } + for name, verbs := range want { + s, ok := SeatNamed(name) + if !ok { + t.Errorf("%s is not in the mesh's set", name) + continue + } + if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0208" { + t.Errorf("%s is %s-scoped under %q", name, s.Scope, s.Decision) + } + var got []string + for _, v := range s.Serves { + got = append(got, v.Name) + if v.Description == "" || v.Input["type"] != "object" { + t.Errorf("%s.%s has no description or no object schema", name, v.Name) + } + } + if !reflect.DeepEqual(got, verbs) { + t.Errorf("%s serves %v, want %v", name, got, verbs) + } + } + // The launcher's menu takes a list, and the layout verb says its actions. + menu, _ := SeatNamed(LauncherSeat) + choices := menu.Serves[0].Input["properties"].(map[string]any)["choices"].(map[string]any) + if choices["type"] != "array" { + t.Errorf("menu's choices are %v, not a list", choices["type"]) + } + display, _ := SeatNamed(DisplayServerSeat) + action := display.Serves[1].Input["properties"].(map[string]any)["action"].(map[string]any) + if !reflect.DeepEqual(action["enum"], []string{"list", "save", "apply"}) { + t.Errorf("layout's actions are %v", action["enum"]) + } + // And they survive the store's JSON, which is where the live set comes from. + if _, err := json.Marshal(graphicalSessionSeats()); err != nil { + t.Fatal(err) + } +} + +// §2: a module may claim one of them, and may not declare it as its own. +func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) { + raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") { + t.Fatalf("a module declared node-display-server as its own: %v", err) + } +} + +func displayServer(name, display string, claims ...string) Manifest { + m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}} + for _, c := range claims { + m.Claims = append(m.Claims, Claim{Name: c}) + } + return m +} + +func windowManager() Manifest { + return Manifest{Module: "i3", Requires: []string{"x11-display"}} +} + +// §3: a display is resolved on the requiring module's own node. +func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) { + cat := shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)) + got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{}) + if err != nil { + t.Fatalf("i3 beside xorg did not resolve: %v", err) + } + if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) { + t.Errorf("resolved %v", names(got)) + } +} + +// §3: never answered by installing a provider, and never by another machine's. +func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) { + cat := shelf(windowManager(), + displayServer("xorg", "x11-display", DisplayServerSeat), + displayServer("xwayland", "x11-display")) + // Another machine runs xorg and says so to the world; it does not count. + world := World{Offered: map[string][]Provider{ + "x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}} + _, err := Resolve(cat, []string{"i3"}, workstation(), world) + if err == nil { + t.Fatal("i3 resolved on a machine with no display of its own") + } + for _, want := range []string{ + `"x11-display" is wanted by i3`, "usable only on the machine that provides it", + "assign one to workstation", "xorg (holds node-display-server)", "xwayland", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q:\n%v", want, err) + } + } + // With a single provider in the catalogue too: one candidate is still not a choice to make + // for somebody, unlike a node-scoped provision without the machine's reach. + _, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", DisplayServerSeat)), + []string{"i3"}, workstation(), World{}) + if err == nil { + t.Fatal("xorg was pulled in for i3") + } + // Not in the first pass, whose refusals take the machine off the network. + if _, err := Resolve(cat, []string{"i3"}, workstation(), World{Unchecked: true}); err != nil { + t.Errorf("the first pass refused: %v", err) + } +} + +func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) { + for _, c := range []struct{ provides, want string }{ + {`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`}, + {`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`}, + } { + _, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want %q, got %v", c.provides, c.want, err) + } + } + m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`)) + if err != nil { + t.Fatal(err) + } + if !m.Provides[0].MachineReach() { + t.Fatal("the reach was not read") + } + back, _ := json.Marshal(m.Provides[0]) + if string(back) != `{"name":"x11-display","reach":"machine"}` { + t.Errorf("written back as %s", back) + } +} + +func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) { + cat := shelf(windowManager(), displayServer("xorg", "x11-display"), + Manifest{Module: "fake-x", Provides: Offers("x11-display")}) + _, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), World{}) + if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) { + t.Fatalf("a provision with and without the machine's reach gave %v", err) + } +} + +// §4: xinitrc and xresources slots, placed by the display server's holder alone. +func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) { + xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}}, + Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}}, + Resources: []map[string]any{ + {"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc", + "content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"}, + {"id": "xresources", "type": "file", "path": "/home/op/.Xresources", + "content": "${shell:xresources:normal}"}, + }} + i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}} + theme := Manifest{Module: "theme", Shell: []ShellCode{ + {For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"}, + {For: "zsh", Slot: "normal", Code: "not for the session"}, + }} + r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + by := map[string]any{} + for _, res := range out { + by[res["id"].(string)] = res["content"] + } + if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" { + t.Errorf("the .xinitrc is %q", got) + } + if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" { + t.Errorf("the .Xresources is %q", got) + } + + // The contributions parse; the placeholders parse only in the holder. + if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` + + `{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil { + t.Fatalf("a session contribution was refused: %v", err) + } + for _, c := range []struct{ claims, content, want string }{ + {``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"}, + {`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"}, + {`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"}, + {`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"}, + } { + raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + + c.content + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err) + } + } + if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` + + `"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil { + t.Errorf("the display server's holder could not place the session's slots: %v", err) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 7d736de..9bc1d8e 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -147,8 +147,17 @@ type Offer struct { // shared by every consumer (novox/hq ADR 0158): software that holds one password or one key // cannot give each consumer a login of its own. The named secret must say how it is taken. Credential *OfferCredential `json:"credential,omitempty"` + // Reach is ReachMachine for a provision usable only on the provider's own machine — a display + // (novox/hq ADR 0208 §3). Node scope already keeps a provision off other machines; what this adds + // is that a requirement for it is never answered by installing a provider: the display server is + // a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked + // is the misassignment research 026 found. Unmet, the requirement is refused naming who could. + Reach string `json:"reach,omitempty"` } +// MachineReach is whether a provision is usable only on its provider's own machine. +func (o Offer) MachineReach() bool { return o.Reach == ReachMachine } + // OfferCredential names which of the provider's own secrets a provision's consumers receive. type OfferCredential struct { Own string `json:"own"` @@ -196,27 +205,29 @@ func (o *Offer) UnmarshalJSON(raw []byte) error { Name string `json:"name"` Scope string `json:"scope,omitempty"` Credential *OfferCredential `json:"credential,omitempty"` + Reach string `json:"reach,omitempty"` } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&full); err != nil { - return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err) + return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach}: %w", err) } - o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential + o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach return nil } // MarshalJSON writes back the short form when there is nothing else to say, so a manifest that // went through the mesh comes out looking like the one that went in. func (o Offer) MarshalJSON() ([]byte, error) { - if o.Scope == "" && o.Credential == nil { + if o.Scope == "" && o.Credential == nil && o.Reach == "" { return json.Marshal(o.Name) } return json.Marshal(struct { Name string `json:"name"` Scope string `json:"scope,omitempty"` Credential *OfferCredential `json:"credential,omitempty"` - }{o.Name, o.Scope, o.Credential}) + Reach string `json:"reach,omitempty"` + }{o.Name, o.Scope, o.Credential, o.Reach}) } // Manifest is everything a module says about itself. @@ -1317,6 +1328,19 @@ func ParseManifest(raw []byte) (Manifest, error) { "%s provides %q at scope %q; a provision is %q or %q", m.Module, p, s, ScopeNode, ScopeMesh)) } + switch { + case offer.Reach == "": + case offer.Reach != ReachMachine: + // The one reach a provision has (novox/hq ADR 0208): a provision reached over the private + // network is mesh scope, and the world reaches nothing but a name. + problems = append(problems, fmt.Sprintf( + "%s provides %q with reach %q; a provision's reach is %q or nothing", + m.Module, p, offer.Reach, ReachMachine)) + case offer.At() != ScopeNode: + problems = append(problems, fmt.Sprintf( + "%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+ + "machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At())) + } if p == m.Module { // Harmless and worth saying: a module always provides its own name, so writing it // suggests the author expected it not to. diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index e573a5d..a825a3a 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -233,6 +233,27 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world local[o.Name] = true } } + // Which names are usable only on their provider's own machine (novox/hq ADR 0208 §3). Also a + // property of the name: a display one provider says is the machine's and another says is not + // would be pulled in for one consumer and refused for the next. + machineReach := map[string]bool{} + plainLocal := map[string]bool{} + for _, m := range catalogue { + for _, o := range m.Provides { + if o.MachineReach() { + machineReach[o.Name] = true + } else if o.At() == ScopeNode { + plainLocal[o.Name] = true + } + } + } + for want := range machineReach { + if plainLocal[want] { + problems = append(problems, fmt.Sprintf( + "the catalogue disagrees about %q: some modules provide it with the machine's reach and "+ + "others without, so a requirement for it would be met differently by each", want)) + } + } for want := range brokered { if local[want] { problems = append(problems, fmt.Sprintf( @@ -499,6 +520,23 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world continue } + // Usable only on its provider's own machine, and not here: refused, never answered by + // installing a provider (novox/hq ADR 0208 §3). The display server is the machine's own role, + // gated by its graphical session; one pulled in for a window manager is the misassignment + // research 026 found. Another node's provider never counts — node scope is never brokered. + if machineReach[want] && !isModule(catalogue, want) { + reported[want] = true + if world.Unchecked { + // The first pass's refusals take a machine off the network; the second says it. + continue + } + problems = append(problems, fmt.Sprintf( + "%q is wanted by %s and is usable only on the machine that provides it, and nothing "+ + "assigned to %s does — %s", want, because[want], node.Name, + machineReachRemedy(catalogue, want, node.Name))) + continue + } + candidates := offers[want] switch len(candidates) { case 0: @@ -1034,3 +1072,38 @@ func eachLocal(needs []Needed, catalogue map[string]Manifest, n Needed) []Needed } return needs } + +// machineReachRemedy names what would meet a requirement with the machine's reach: every module in +// the catalogue that provides it, each with the node seats it holds — for a display, the holders of +// node-display-server (novox/hq ADR 0208 §3), named by the seat because that is the role being +// asked for, without this code knowing which seat any provision belongs to. +func machineReachRemedy(catalogue map[string]Manifest, want, node string) string { + var named []string + for _, name := range sortedKeys(catalogue) { + m := catalogue[name] + provides := false + for _, o := range m.Provides { + if o.Name == want { + provides = true + } + } + if !provides { + continue + } + var held []string + for _, c := range m.Claims { + if c.At() == ScopeNode { + held = append(held, c.Name) + } + } + if len(held) > 0 { + named = append(named, fmt.Sprintf("%s (holds %s)", name, strings.Join(held, ", "))) + } else { + named = append(named, name) + } + } + if len(named) == 0 { + return "and nothing in the catalogue provides it" + } + return fmt.Sprintf("assign one to %s: %s", node, strings.Join(named, "; ")) +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index b7b5e68..02d177c 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -49,7 +49,7 @@ type Seat struct { // 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 = []Seat{ +var defaultSeats = append([]Seat{ // The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's // events belong to the role, so they keep their address while the holder is replaced. No accepts, // so no work queue is raised for it — only what its holder may say. @@ -183,7 +183,9 @@ var defaultSeats = []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). {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"}, -} +}, + // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. + graphicalSessionSeats()...) // A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for // a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused; diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 91095d3..cc06a02 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -46,12 +46,12 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // 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 "+ + // Thirty-two since the graphical session's eleven (novox/hq ADR 0208); twenty-one with + // node-package-manager and node-container-runtime (ADR 0207); nineteen with node-environment and + // node-login-shell (ADR 0203, ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer + // once the retired mesh-build-machine row goes, when no registered manifest claims it any more. + if len(Seats()) != 32 { + t.Errorf("the mesh defines %d seats rather than 32; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } From 11b654499bb67c2b22fe54e0de663c188b6808ab Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:42:00 +0200 Subject: [PATCH 3/3] Several modules may add groups to one account; its shell and home stay one module's The host only ever adds groups, so the container runtime's module can put the operator in its group while the shell's module sets the same account's shell. --- internal/catalogue/resolve.go | 22 ++++++++++++ internal/catalogue/shared_account_test.go | 42 +++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 internal/catalogue/shared_account_test.go diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index a825a3a..bece43e 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -857,6 +857,28 @@ func checkResources(modules []Manifest) []string { // does not exist is the manifest's own problem, refused where it was made. dirs := dirsFor(m, Rendering{}) for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" { + // **An account is shared; what it is set to is not.** Several modules may need one + // login: the shell's module sets its shell, the container runtime's puts it in the + // `docker` group. The host only ever adds groups — it never takes the account out of + // one, not even when the resource that named it is undeclared — so groups from + // several modules cannot contradict each other and are not owned. A shell or a home + // is one value, and two modules setting it would each be undone by the other's + // apply: each stays one module's per node, and two are refused naming both. + name, _ := r["name"].(string) + for _, field := range []string{"shell", "home"} { + if v, ok := r[field].(string); !ok || v == "" || name == "" { + continue + } + key := "user " + field + " " + name + if other, taken := owner[key]; taken && other != m.Module { + problems = append(problems, fmt.Sprintf( + "%s and %s both set the %s of the user %q", other, m.Module, field, name)) + } + owner[key] = m.Module + } + continue + } for _, field := range []string{"path", "unit", "name", "package"} { value, ok := r[field].(string) if !ok || value == "" { diff --git a/internal/catalogue/shared_account_test.go b/internal/catalogue/shared_account_test.go new file mode 100644 index 0000000..16a7f74 --- /dev/null +++ b/internal/catalogue/shared_account_test.go @@ -0,0 +1,42 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// One account, several modules: the shell's module sets its shell, the container runtime's adds it +// to a group. Groups are only ever added by the host, so they are contributed; a shell or a home is +// one value, owned by one module per node. + +func userResource(fields map[string]any) map[string]any { + r := map[string]any{"id": "operator", "type": "user", "name": "op"} + for k, v := range fields { + r[k] = v + } + return r +} + +func TestAShellAndAGroupOnOneAccountFromTwoModulesResolve(t *testing.T) { + zsh := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{"shell": "/usr/bin/zsh"})}} + docker := Manifest{Module: "docker", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"docker"}})}} + other := Manifest{Module: "media", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"video"}})}} + if problems := checkResources([]Manifest{zsh, docker, other}); len(problems) != 0 { + t.Fatalf("a shell and two modules' groups on one account were refused: %v", problems) + } + if _, err := Resolve(shelf(zsh, docker, other), []string{"zsh", "docker", "media"}, workstation(), World{}); err != nil { + t.Fatalf("the three did not resolve together: %v", err) + } +} + +func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) { + for _, field := range []string{"shell", "home"} { + a := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{field: "/one"})}} + b := Manifest{Module: "fish", Resources: []map[string]any{userResource(map[string]any{field: "/two", "groups": []any{"x"}})}} + problems := checkResources([]Manifest{a, b}) + want := `zsh and fish both set the ` + field + ` of the user "op"` + if len(problems) != 1 || !strings.Contains(problems[0], want) { + t.Errorf("two modules setting %s gave %v, want %q", field, problems, want) + } + } +}