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/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 a33f761..bece43e 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. @@ -229,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( @@ -495,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: @@ -628,6 +670,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} @@ -804,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 == "" { @@ -1019,3 +1094,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/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..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. @@ -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 @@ -172,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 31d8baa..cc06a02 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 "+ + // 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()) } } 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) + } + } +} 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{