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.
This commit is contained in:
@@ -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:<shell>:<slot>}, 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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, "; "))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user