diff --git a/internal/catalogue/environment_into.go b/internal/catalogue/environment_into.go index d457cd8..44d9711 100644 --- a/internal/catalogue/environment_into.go +++ b/internal/catalogue/environment_into.go @@ -110,6 +110,8 @@ const ( var ( ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`) ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`) + // ofContributed is either kind of contributed text, matched together so both fill in one pass. + ofContributed = regexp.MustCompile(`\$\{(shell|contribution):([^}]*)\}`) ) // variableName is a POSIX shell variable name, which is also what environment.d accepts. @@ -223,6 +225,7 @@ func (m Manifest) contributionPlaceholderProblems() []string { var problems []string for _, r := range m.Resources { problems = append(problems, placeholderProblems(m, r)...) + problems = append(problems, seatPlaceholderProblems(m, r)...) } return problems } @@ -545,7 +548,7 @@ func shellCode(modules []Manifest, shell, slot string) string { // is filled before the shell's code is, and each is replaced in a single pass over what the holder // wrote, so a contributed piece is never scanned again. func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string) error { - if problems := placeholderProblems(m, resource); len(problems) > 0 { + if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 { return fmt.Errorf("%s", problems[0]) } content, ok := resource["content"].(string) @@ -564,10 +567,17 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, return env.posix() }) } - if ofShell.MatchString(content) { - content = ofShell.ReplaceAllStringFunc(content, func(placeholder string) string { - shell, slot, _ := strings.Cut(ofShell.FindStringSubmatch(placeholder)[1], ":") - return shellCode(modules, shell, slot) + // Shell code and seat contributions in one pass (novox/hq ADR 0212): both are contributed text + // the controller does not read, so neither may be scanned after the other is in place — a + // contributed line that happened to spell the other's placeholder would be filled. + if ofContributed.MatchString(content) { + content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string { + found := ofContributed.FindStringSubmatch(placeholder) + first, second, _ := strings.Cut(found[2], ":") + if found[1] == "contribution" { + return seatContributions(modules, first, second) + } + return shellCode(modules, first, second) }) } resource["content"] = content diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go index 6271fee..1407bc4 100644 --- a/internal/catalogue/graphical_session.go +++ b/internal/catalogue/graphical_session.go @@ -41,16 +41,20 @@ func graphicalSessionSeats() []Seat { "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)}, - }}, + // It receives window-manager configuration lines from every other module (novox/hq ADR 0212): + // bindings, start-up commands, rules — placed by the session's holder, never written into + // its directory by the contributor. + {Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided, + Receives: []Receivable{{Kind: "config", Comment: "#"}}, 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.", diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 9bc1d8e..65777df 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -539,6 +539,11 @@ type Manifest struct { // the holder of node-login-shell put the slot's placeholder. Shell []ShellCode `json:"shell,omitempty"` + // Contributions are configuration this module gives the holder of a seat it does not hold, in + // that tool's own grammar (novox/hq ADR 0212): a seat, a kind the seat receives, and the text. The + // holder places them; the module depends on the seat (ADR 0210 §3). + Contributions []SeatContribution `json:"contributions,omitempty"` + // Guards are ports of this module's the mesh refuses on an adopted node except from the // private network and from the machine itself (novox/hq ADR 0100) — the store's port and the // broker's management port. The ports the software uses; the mesh guards where the machine @@ -1846,6 +1851,7 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, m.environmentProblems()...) problems = append(problems, m.shellProblems()...) problems = append(problems, m.contributionPlaceholderProblems()...) + problems = append(problems, m.seatContributionProblems()...) for i, r := range m.Resources { id, _ := r["id"].(string) diff --git a/internal/catalogue/seat_contributions.go b/internal/catalogue/seat_contributions.go new file mode 100644 index 0000000..a75ad36 --- /dev/null +++ b/internal/catalogue/seat_contributions.go @@ -0,0 +1,156 @@ +package catalogue + +import ( + "fmt" + "regexp" + "strings" +) + +// A module contributes to a seat it does not hold (novox/hq ADR 0212). +// +// ADR 0210 made a tool's configuration its seat holder's, and every other module's way in a +// contribution to the seat. The environment, the shell's slots and the power moments each became a +// field of their own; this is the general form, so a new seat that takes contributions is a row in +// the seat table rather than a change to the manifest: a contribution names a seat, a kind that +// seat receives, and text in the tool's own grammar, which the controller never reads. + +// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5). +const HotkeysSeat = "node-hotkeys" + +// SeatContribution is one piece of configuration a module gives a seat's holder to place. +type SeatContribution struct { + // Seat is the seat whose holder places it. + Seat string `json:"seat"` + // Kind is which of the seat's receivable kinds it is. + Kind string `json:"kind"` + // Content is the text, in the tool's own grammar. Never interpreted. + Content string `json:"content"` +} + +// ofContribution is where a holder places a kind: ${contribution::}. Loose inside the +// braces, so a misspelt seat or kind is found and refused rather than written out as text. +var ofContribution = regexp.MustCompile(`\$\{contribution:([^}]*)\}`) + +// receivable is what a seat receives of a kind, with the seat's canonical name; false when the seat +// is unknown or does not receive it. +func receivable(seat, kind string) (Seat, Receivable, bool) { + s, known := SeatNamed(seat) + if !known { + return Seat{}, Receivable{}, false + } + for _, r := range s.Receives { + if r.Kind == kind { + return s, r, true + } + } + return s, Receivable{}, false +} + +// kindsOf names a seat's receivable kinds for a refusal. +func kindsOf(s Seat) string { + if len(s.Receives) == 0 { + return "it receives no contributions" + } + var kinds []string + for _, r := range s.Receives { + kinds = append(kinds, r.Kind) + } + return "it receives " + strings.Join(kinds, ", ") +} + +// seatContributionProblems is what is wrong with this module's contributions, from the manifest +// alone (novox/hq ADR 0212 §2). +func (m Manifest) seatContributionProblems() []string { + var problems []string + for i, c := range m.Contributions { + s, _, ok := receivable(c.Seat, c.Kind) + switch { + case s.Name == "": + problems = append(problems, fmt.Sprintf( + "%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat)) + case !ok: + problems = append(problems, fmt.Sprintf( + "%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)", + m.Module, i+1, s.Name, c.Kind, kindsOf(s))) + } + if strings.TrimSpace(c.Content) == "" { + problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1)) + } + } + return problems +} + +// seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a +// file's content, naming a seat and a kind it receives, and only by a module that claims that seat +// — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3). +func seatPlaceholderProblems(m Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + found := ofContribution.FindAllStringSubmatch(v, -1) + if len(found) == 0 { + continue + } + if field != "content" { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s in its %s; contributions are placed only in a file's content", + m.Module, r["id"], found[0][0], field)) + continue + } + for _, f := range found { + seat, kind, two := strings.Cut(f[1], ":") + s, _, ok := receivable(seat, kind) + if !two || !ok { + detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat) + if s.Name != "" { + detail = s.Name + ": " + kindsOf(s) + } + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s; a contribution is ${contribution::} (%s)", + m.Module, r["id"], f[0], detail)) + continue + } + if !m.ClaimsSeat(s.Name) { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's contributions to a "+ + "seat are placed by its holder alone (novox/hq ADR 0212)", + m.Module, r["id"], f[0], m.Module, s.Name)) + } + } + } + return problems +} + +// seatContributions is every module's contribution of one kind to one seat (novox/hq ADR 0212 §3): +// in module order, each module's pieces in the order it declared them, each module's preceded by a +// comment line naming it in the tool's grammar, and empty when nothing is contributed. +func seatContributions(modules []Manifest, seat, kind string) string { + s, r, ok := receivable(seat, kind) + if !ok { + return "" + } + var b strings.Builder + for _, m := range inModuleOrder(modules) { + named := false + for _, c := range m.Contributions { + if c.Kind != kind { + continue + } + if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name { + continue + } + if !named { + fmt.Fprintf(&b, "%s %s\n", r.Comment, m.Module) + named = true + } + b.WriteString(c.Content) + if !strings.HasSuffix(c.Content, "\n") { + b.WriteString("\n") + } + } + } + return b.String() +} diff --git a/internal/catalogue/seat_contributions_test.go b/internal/catalogue/seat_contributions_test.go new file mode 100644 index 0000000..303e40b --- /dev/null +++ b/internal/catalogue/seat_contributions_test.go @@ -0,0 +1,117 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// Defends novox/hq ADR 0212: a seat says what it receives, its holder places it, and a contribution +// depends on the seat. + +func hotkeysHolder() Manifest { + return Manifest{Module: "triggerhappy", Claims: []Claim{{Name: HotkeysSeat}}, Resources: []map[string]any{ + {"id": "triggers", "type": "file", "path": "/etc/triggerhappy/triggers.d/mesh.conf", + "content": "# the mesh's triggers\n${contribution:node-hotkeys:trigger}"}, + }} +} + +func TestAContributionReachesTheHoldersFileInModuleOrderNamedByModule(t *testing.T) { + laptop := Manifest{Module: "laptop", Contributions: []SeatContribution{ + {Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_PROG1 1 play ${machine:account-home}"}, + {Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"}, + }} + another := Manifest{Module: "another", Contributions: []SeatContribution{ + {Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute ${shell:zsh:first}"}, + }} + unrelated := Manifest{Module: "bar", Contributions: []SeatContribution{ + {Seat: DisplaySessionSeat, Kind: "config", Content: "bar { }"}, + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder(), laptop, another, unrelated}} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + var file map[string]any + for _, res := range out { + if res["id"] == "triggerhappy.triggers" { + file = res + } + } + if file == nil { + t.Fatalf("the holder's file was not composed: %v", out) + } + // Module order; each module named once; text never read, so neither ${machine:…} nor ${shell:…} + // inside a contribution is filled. + want := "# the mesh's triggers\n" + + "# another\nKEY_MUTE 1 mute ${shell:zsh:first}\n" + + "# laptop\nKEY_PROG1 1 play ${machine:account-home}\nKEY_F21 1 touchpad\n" + if got := file["content"]; got != want { + t.Fatalf("the holder's file is\n%s\nnot\n%s", got, want) + } +} + +func TestNoContributionPlacesNothing(t *testing.T) { + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder()}} + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == "triggerhappy.triggers" && res["content"] != "# the mesh's triggers\n" { + t.Fatalf("an empty kind left %q", res["content"]) + } + } +} + +func TestAContributionToASeatThatDoesNotReceiveItIsRefused(t *testing.T) { + cases := map[string]string{ + `{"seat":"node-hotkeys","kind":"config","content":"x"}`: "it receives trigger", + `{"seat":"node-nothing","kind":"trigger","content":"x"}`: "the mesh does not define", + `{"seat":"node-hotkeys","kind":"trigger","content":" "}`: "has no content", + `{"seat":"node-display-session","kind":"trigger","content":"x"}`: "it receives config", + } + for c, want := range cases { + raw := `{"module":"laptop","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } +} + +func TestAContributionPlaceholderOutsideTheHolderIsRefused(t *testing.T) { + raw := `{"module":"laptop","resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:trigger}"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "laptop does not claim node-hotkeys") { + t.Errorf("a placeholder outside the holder was accepted: %v", err) + } + raw = `{"module":"triggerhappy","claims":[{"name":"node-hotkeys"}],"resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:keys}"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "it receives trigger") { + t.Errorf("a placeholder for a kind the seat does not receive was accepted: %v", err) + } +} + +func TestAContributionDependsOnItsSeat(t *testing.T) { + m := Manifest{Module: "laptop", Contributions: []SeatContribution{ + {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, + {Seat: DisplaySessionSeat, Kind: "config", Content: "y"}, + }} + if got, want := DependsOn(m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) { + t.Errorf("depends on %v, want %v", got, want) + } + catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()} + if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"laptop"}); err == nil || + !strings.Contains(err.Error(), HotkeysSeat) { + t.Errorf("a contributor without the holder was accepted: %v", err) + } +} + +func TestTheHotkeysSeatIsTheMeshsAndReceivesTriggers(t *testing.T) { + s, ok := SeatNamed(HotkeysSeat) + if !ok || s.Scope != ScopeNode || len(s.Receives) != 1 || s.Receives[0].Kind != "trigger" { + t.Fatalf("%+v %v", s, ok) + } + d, _ := SeatNamed(DisplaySessionSeat) + if len(d.Receives) != 1 || d.Receives[0].Kind != "config" { + t.Fatalf("the display session receives %+v", d.Receives) + } +} diff --git a/internal/catalogue/seat_dependencies.go b/internal/catalogue/seat_dependencies.go index dcb7b61..8044993 100644 --- a/internal/catalogue/seat_dependencies.go +++ b/internal/catalogue/seat_dependencies.go @@ -114,6 +114,13 @@ func contributedTo(m Manifest) []string { for _, c := range m.Shell { out = append(out, placerOf(c.For)) } + // Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the + // mesh does not define is refused at registration and depends on nothing here. + for _, c := range m.Contributions { + if s, known := SeatNamed(c.Seat); known { + out = append(out, s.Name) + } + } return out } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index ab7f517..b64a618 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -40,10 +40,22 @@ type Seat struct { // Serves carries each verb in full — name, description, schema — because a role's tools are the // mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2). Serves []Verb + // Receives is what other modules may contribute to the seat's holder, by kind (novox/hq ADR + // 0212): each kind is text in the tool's own grammar, placed by the holder with + // ${contribution::}. Compiled, never stored: like the protocol, it is the mesh's + // definition of the role, and the store's rows carry no column for it. + Receives []Receivable // Decision is the record that made it a seat. Decision string } +// Receivable is one kind of contribution a seat receives (novox/hq ADR 0212 §2): its name, and the +// comment prefix of the tool's grammar, with which the controller names each contributing module. +type Receivable struct { + Kind string + Comment string +} + // defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the // fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is // written; the store's table is seeded from it and thereafter is the live, editable copy. @@ -176,6 +188,11 @@ var defaultSeats = append([]Seat{ // code modules contribute for the power moments, and publishes the machine's power states as // its events. Every machine has one — every machine boots and shuts down. No verbs yet. {Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"}, + // The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager + // does not — a laptop's vendor keys — run by one module per machine, which owns its + // configuration. Every other module with keys contributes trigger lines to it. + {Name: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212", + Receives: []Receivable{{Kind: "trigger", Comment: "#"}}}, // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client // model change, not a rename, so it stays until that is built. {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, @@ -238,6 +255,10 @@ func UseSeats(s []Seat) { row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves } } + // What a seat receives is never stored (novox/hq ADR 0212), so it is always the compiled one. + if d, known := byName[row.Name]; known { + row.Receives = d.Receives + } merged = append(merged, row) } seats = merged diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 1852a0d..9fe97c4 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -46,13 +46,13 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Thirty-three with node-power (novox/hq ADR 0211); thirty-two since the graphical session's + // Thirty-four with node-hotkeys (novox/hq ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the graphical session's // eleven (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()) != 33 { - t.Errorf("the mesh defines %d seats rather than 33; the set is closed, so a change here is "+ + if len(Seats()) != 34 { + t.Errorf("the mesh defines %d seats rather than 34; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } }