From d69e19103c0a3e0878c9c4ee2ef45a4378c8c661 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:38:53 +0200 Subject: [PATCH] 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()) } }