From b1acb3d9def5ffc42727daf7752f828b60a104dd Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:35:27 +0200 Subject: [PATCH] Let a seat receive blocks as data its holder renders, placed where the machine has the hardware (hq ADR 0255) A module adding a battery to the bar had to write i3status-rust's TOML, so a second bar could not take its place. node-bar now receives a bar-neutral block: the contributor says what it shows, the holder renders it with its own template, places every bar and place once, and a contribution may name a capability the machine must report. The block is offered: the power module runs on servers without a bar. --- internal/catalogue/backup_test.go | 2 +- internal/catalogue/data_test.go | 6 +- internal/catalogue/declaration.go | 2 +- internal/catalogue/environment_into.go | 4 +- internal/catalogue/graphical_session.go | 102 ++++++- internal/catalogue/manifest.go | 5 + internal/catalogue/resolve.go | 6 +- internal/catalogue/seat_contributions.go | 49 ++- internal/catalogue/seat_data.go | 370 +++++++++++++++++++++++ internal/catalogue/seat_data_test.go | 203 +++++++++++++ internal/catalogue/seat_dependencies.go | 5 +- internal/catalogue/seats.go | 7 + 12 files changed, 744 insertions(+), 17 deletions(-) create mode 100644 internal/catalogue/seat_data.go create mode 100644 internal/catalogue/seat_data_test.go diff --git a/internal/catalogue/backup_test.go b/internal/catalogue/backup_test.go index 7dc65f62..d1bc8998 100644 --- a/internal/catalogue/backup_test.go +++ b/internal/catalogue/backup_test.go @@ -32,7 +32,7 @@ func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { if err != nil { t.Fatal(err) } - placed, err := seatContributions([]Manifest{pg, mail}, BackupSeat, "backup", Rendering{}, nil) + placed, err := seatContributions([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", Rendering{}, nil, nil) if err != nil { t.Fatal(err) } diff --git a/internal/catalogue/data_test.go b/internal/catalogue/data_test.go index 6e55f210..bb1d81d8 100644 --- a/internal/catalogue/data_test.go +++ b/internal/catalogue/data_test.go @@ -174,7 +174,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { "resources":[{"id":"meta","type":"directory","mode":"0700"}]}`) facts := map[string]string{"account-home": "/home/op"} with := Rendering{Settings: SettingsBy{"media": {{From: "node", Values: map[string]any{AccessesSetting: map[string]any{"films": "/tank/films"}}}}}} - backup, err := seatContributions([]Manifest{pg, agent, media}, BackupSeat, BackupKindBackup, with, facts) + backup, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindBackup, with, facts, nil) if err != nil { t.Fatal(err) } @@ -182,7 +182,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { if backup != want { t.Fatalf("backup lines:\n%s\nwant:\n%s", backup, want) } - data, err := seatContributions([]Manifest{pg, agent, media}, BackupSeat, BackupKindData, with, facts) + data, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindData, with, facts, nil) if err != nil { t.Fatal(err) } @@ -192,7 +192,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { if data != want { t.Fatalf("data lines:\n%s\nwant:\n%s", data, want) } - if _, err := seatContributions([]Manifest{agent}, BackupSeat, BackupKindData, Rendering{}, nil); err == nil { + if _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil { t.Fatal("a home directory with no account on the machine reached the holder as a placeholder") } // What keeps something irreplaceable depends on the machine's backup holder — it backs it up or diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 7c25a7bc..c11dd755 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -970,7 +970,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on // the node, as the jails are, and **last of every placeholder pass**: shell code is a // shell's own syntax, full of `${…}` no pass above should ever be shown. - if err := contributionsInto(copied, m, r.Modules, thisMachine, with); err != nil { + if err := contributionsInto(copied, m, r.Modules, thisMachine, with, r.Capabilities); err != nil { return nil, err } copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) diff --git a/internal/catalogue/environment_into.go b/internal/catalogue/environment_into.go index 2cb6c17a..8ed1377a 100644 --- a/internal/catalogue/environment_into.go +++ b/internal/catalogue/environment_into.go @@ -547,7 +547,7 @@ func shellCode(modules []Manifest, shell, slot string) string { // `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment // 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, with Rendering) error { +func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool) error { if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 { return fmt.Errorf("%s", problems[0]) } @@ -576,7 +576,7 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, found := ofContributed.FindStringSubmatch(placeholder) first, second, _ := strings.Cut(found[2], ":") if found[1] == "contribution" { - placed, err := seatContributions(modules, first, second, with, facts) + placed, err := seatContributions(modules, m, found[2], with, facts, caps) if err != nil && failed == nil { failed = err } diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go index 1407bc4d..43663e87 100644 --- a/internal/catalogue/graphical_session.go +++ b/internal/catalogue/graphical_session.go @@ -1,5 +1,10 @@ package catalogue +import ( + "fmt" + "strings" +) + // 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 @@ -19,7 +24,8 @@ const ( ) // 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 +// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead, +// ADR 0255), 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" @@ -93,7 +99,11 @@ func graphicalSessionSeats() []Seat { {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}, + // It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which + // the holder renders in its own grammar. A module that knows something about the machine — the + // power module its battery — gives the block; the bar knows no machine's hardware. + {Name: BarSeat, Scope: ScopeNode, Decision: decided, + Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}}, {Name: CompositorSeat, Scope: ScopeNode, Decision: decided}, {Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided}, } @@ -107,3 +117,91 @@ func withEnum(s map[string]any, property string, values ...string) map[string]an p["enum"] = values return s } + +// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255). +const BarKindBlock = "block" + +// barShows is what a contributed block may show, and the options each takes — the bar-neutral +// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template +// renders it (the shape's examples) before it is accepted. +var barShows = map[string]map[string]barOption{ + // The machine's battery: its charge, and how long it lasts or takes to fill. + "battery": {"device": {kind: "text"}}, + // One line a command prints, run every interval seconds: what the bar cannot know itself. + "command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}}, +} + +type barOption struct { + kind string // text, or seconds (a whole number from 1 to 86400) + required bool +} + +// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order, +// what it shows, and that kind's options. +// +// - bar: top or bottom; +// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the +// clock) — the two places every holder keeps for contributed blocks; +// - order: 0–99 within the place, 50 when absent; +// - shows: battery or command; options: what that kind takes. +func barBlockShape() *Shape { + return &Shape{ + Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}}, + {Field: "place", Values: []string{"resources", "status"}}}, + Order: "order", + Check: barBlockProblems, + Examples: []map[string]any{ + {"bar": "bottom", "place": "status", "shows": "battery"}, + {"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}}, + {"bar": "bottom", "place": "status", "shows": "command", + "options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}}, + }, + } +} + +func barBlockProblems(data map[string]any) []string { + var problems []string + for field := range data { + if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) { + problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field)) + } + } + shows, _ := data["shows"].(string) + takes, known := barShows[shows] + if !known { + return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"], + strings.Join(sortedKeys(barShows), ", "))) + } + options := map[string]any{} + if raw, has := data["options"]; has { + o, ok := raw.(map[string]any) + if !ok { + return append(problems, "a block's options are an object") + } + options = o + } + for name, v := range options { + opt, ok := takes[name] + if !ok { + problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name, + orNothing(sortedKeys(takes)))) + continue + } + switch opt.kind { + case "text": + if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" { + problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name)) + } + case "seconds": + if _, whole := wholeIn(v, 1, 86400); !whole { + problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name)) + } + } + } + for _, name := range sortedKeys(takes) { + if _, has := options[name]; takes[name].required && !has { + problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name)) + } + } + return problems +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index ded912a7..2c64d59c 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -60,6 +60,10 @@ type Claim struct { // module's own `tools` must list every verb the seat promises, which is how a module named // like its seat — the catalogue, the records — says they are one and the same. Serves []string `json:"serves,omitempty"` + // Renders is how this holder writes each kind its seat receives as data, by kind: a Go + // text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR + // 0255). The data is the mesh's, the format the holder's, as a module's facts template is. + Renders map[string]string `json:"renders,omitempty"` } // ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's @@ -2030,6 +2034,7 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, m.shellProblems()...) problems = append(problems, m.contributionPlaceholderProblems()...) problems = append(problems, m.seatContributionProblems()...) + problems = append(problems, m.placementProblems()...) problems = append(problems, m.dataProblems()...) problems = append(problems, m.upgradeProblems()...) diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 8d140fef..2cfd163c 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -134,6 +134,10 @@ type Resolution struct { // here without a store lookup. Account string AccountHome string + // Capabilities are the machine's, as its profile reported them, carried from the node so a + // contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255) + // is decided here without a store lookup. + Capabilities map[string]bool // Modules in the order they were resolved: assigned first, then what they pulled in. Modules []Manifest @@ -699,7 +703,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world } resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain, - Account: node.Account, AccountHome: node.AccountHome, + Account: node.Account, AccountHome: node.AccountHome, Capabilities: node.Capabilities, Because: because, Needs: needs, Unhostable: unhostable, Kept: kept} for _, n := range providersFirst(order, catalogue) { resolution.Modules = append(resolution.Modules, catalogue[n]) diff --git a/internal/catalogue/seat_contributions.go b/internal/catalogue/seat_contributions.go index 12365123..46dc156d 100644 --- a/internal/catalogue/seat_contributions.go +++ b/internal/catalogue/seat_contributions.go @@ -30,7 +30,14 @@ type SeatContribution struct { // 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"` + Content string `json:"content,omitempty"` + // Data is the piece in the seat's own shape, for a kind the seat receives as data (novox/hq ADR + // 0255): the holder renders it with its template, and Content is then empty. + Data map[string]any `json:"data,omitempty"` + // IfCapability names a capability the machine must report for this contribution to be placed + // there (novox/hq ADR 0255): a battery's block only where the node-engine found a battery. The + // composition decides, from the machine's facts, not the tool at run time. + IfCapability string `json:"if-capability,omitempty"` } // ofContribution is where a holder places a kind: ${contribution::}. Loose inside the @@ -92,7 +99,17 @@ func (m Manifest) seatContributionProblems() []string { "%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) == "" { + if c.IfCapability != "" && !capabilityName.MatchString(c.IfCapability) { + problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+ + "capability's name", m.Module, i+1, c.IfCapability)) + } + switch { + case ok && r.Shape != nil: + problems = append(problems, shapedProblems(m.Module, i, r, c)...) + case len(c.Data) > 0: + problems = append(problems, fmt.Sprintf("%s's contribution %d has data; %s receives %s as text, "+ + "given as `content`", m.Module, i+1, s.Name, c.Kind)) + case strings.TrimSpace(c.Content) == "": problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1)) } } @@ -120,8 +137,15 @@ func seatPlaceholderProblems(m Manifest, r map[string]any) []string { continue } for _, f := range found { - seat, kind, two := strings.Cut(f[1], ":") - s, _, ok := receivable(seat, kind) + seat, rest, two := strings.Cut(f[1], ":") + // A kind received as data may be narrowed by its shape's placing fields (novox/hq ADR 0255). + kind, where, _ := strings.Cut(rest, ":") + s, rcv, ok := receivable(seat, kind) + if two && ok { + if _, err := placeholderWhere(rcv, where); err != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) + } + } if !two || !ok { detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat) if s.Name != "" { @@ -152,17 +176,30 @@ func seatPlaceholderProblems(m Manifest, r map[string]any) []string { // A directory placed in a person's home is named `${machine:account-home}/…` (novox/hq to-be 29); in a // kind that takes directories that is filled from the machine's facts as well, so the holder reads a // path, never a placeholder (novox/hq ADR 0233: an item in the operator's home is backed up too). -func seatContributions(modules []Manifest, seat, kind string, with Rendering, facts map[string]string) (string, error) { +// +// A kind received as data is rendered through the holder's template instead, narrowed by where +// (novox/hq ADR 0255); and a contribution naming a capability this machine did not report is left +// out, of either form. +func seatContributions(modules []Manifest, holder Manifest, placeholder string, with Rendering, facts map[string]string, caps map[string]bool) (string, error) { + seat, rest, _ := strings.Cut(placeholder, ":") + kind, whereText, _ := strings.Cut(rest, ":") s, r, ok := receivable(seat, kind) if !ok { return "", nil } + if r.Shape != nil { + where, err := placeholderWhere(r, whereText) + if err != nil { + return "", err + } + return shapedContributions(modules, holder, s, r, where, caps) + } var failed error var b strings.Builder for _, m := range inModuleOrder(modules) { named := false for _, c := range m.allContributions() { - if c.Kind != kind { + if c.Kind != kind || !capable(c, caps) { continue } if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name { diff --git a/internal/catalogue/seat_data.go b/internal/catalogue/seat_data.go new file mode 100644 index 00000000..b0405eaa --- /dev/null +++ b/internal/catalogue/seat_data.go @@ -0,0 +1,370 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "regexp" + "sort" + "strings" + "text/template" +) + +// A kind a seat receives as data, rendered by its holder (novox/hq ADR 0255). +// +// ADR 0212 made a contribution text in the tool's own grammar, which the controller places and never +// reads. That is right where the tool is the seat's for good — a hotkey daemon's trigger lines — and +// wrong where the seat's whole point is that its tool can be replaced: a module adding a battery to the +// bar would write i3status-rust's TOML, and a second bar would have to read it. So a seat may define a +// kind as data instead. The seat says the data's shape; a contributor gives data in that shape; the +// holder gives a template that renders one piece of it into its tool's grammar, as a module's facts +// template renders the roster (`facts`): the data is the mesh's, the format is the holder's. +// +// What the holder places is narrowed by the shape's placing fields — the bar's `bar` and `place` — in +// the placeholder, `${contribution:::=,…}`, and every combination of them is +// placed by the holder exactly once, so nothing a module contributes is dropped by a holder that forgot +// a corner of the shape. + +// Shape is what a kind received as data looks like. +type Shape struct { + // Places are the fields a holder narrows its placeholders by, each with every value it takes. + Places []PlaceField + // Order is the whole-number field that orders the pieces within one placeholder (lowest first, + // then module order); absent in a piece means OrderDefault. + Order string + // Check is what else is wrong with one piece's data, beyond its placing fields and its order. + Check func(data map[string]any) []string `json:"-"` + // Examples are pieces every holder's template must render, one for each variant the shape has, + // so a holder that cannot render one is refused at registration and not found on a machine. + Examples []map[string]any +} + +// PlaceField is one field a holder places by, and the values it takes. +type PlaceField struct { + Field string + Values []string +} + +// OrderDefault is a piece's order when it gives none: the middle of 0–99. +const OrderDefault = 50 + +// capabilityName is what an `if-capability` may name: a capability as a node's profile reports it. +var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// renderFuncs are the functions a holder's template may call. `quote` writes a value as a JSON +// string — a valid basic string in TOML and in most tools' grammars — so a contributed command +// with quotes in it cannot break out of the holder's line. +var renderFuncs = template.FuncMap{ + "quote": func(v any) (string, error) { + b, err := json.Marshal(fmt.Sprint(v)) + return string(b), err + }, +} + +// shapedProblems is what is wrong with one contribution of a kind received as data. +func shapedProblems(module string, i int, r Receivable, c SeatContribution) []string { + var problems []string + at := fmt.Sprintf("%s's contribution %d to %s (%s)", module, i+1, c.Seat, c.Kind) + if strings.TrimSpace(c.Content) != "" { + problems = append(problems, at+" has content; this kind is data, given as `data` (novox/hq ADR 0255)") + } + if len(c.Data) == 0 { + return append(problems, at+" has no data") + } + for _, p := range r.Shape.Places { + v, _ := c.Data[p.Field].(string) + if !oneOf(p.Values, v) { + problems = append(problems, fmt.Sprintf("%s says %s %q; it is one of %s", + at, p.Field, fmt.Sprint(c.Data[p.Field]), strings.Join(p.Values, ", "))) + } + } + if r.Shape.Order != "" { + if v, has := c.Data[r.Shape.Order]; has { + if _, ok := wholeIn(v, 0, 99); !ok { + problems = append(problems, fmt.Sprintf("%s says %s %v; it is a whole number from 0 to 99", + at, r.Shape.Order, v)) + } + } + } + if r.Shape.Check != nil { + for _, p := range r.Shape.Check(c.Data) { + problems = append(problems, at+": "+p) + } + } + return problems +} + +// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float. +func wholeIn(v any, lo, hi int) (int, bool) { + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f, ok = float64(i), true + } + } + if !ok || f != math.Trunc(f) || f < float64(lo) || f > float64(hi) { + return 0, false + } + return int(f), true +} + +// placeholderWhere parses the narrowing part of a placeholder, `bar=bottom,place=status`, against the +// kind's shape. Empty narrows nothing. +func placeholderWhere(r Receivable, where string) (map[string]string, error) { + out := map[string]string{} + if where == "" { + return out, nil + } + if r.Shape == nil { + return nil, fmt.Errorf("%s is text, which a placeholder does not narrow", r.Kind) + } + for _, pair := range strings.Split(where, ",") { + field, value, ok := strings.Cut(pair, "=") + var p *PlaceField + for i := range r.Shape.Places { + if r.Shape.Places[i].Field == field { + p = &r.Shape.Places[i] + } + } + switch { + case !ok || p == nil: + return nil, fmt.Errorf("%q narrows by %s, and %s is placed by %s", where, field, r.Kind, placeFields(r)) + case !oneOf(p.Values, value): + return nil, fmt.Errorf("%q says %s=%s; %s is one of %s", where, field, value, field, strings.Join(p.Values, ", ")) + case out[field] != "": + return nil, fmt.Errorf("%q names %s twice", where, field) + } + out[field] = value + } + return out, nil +} + +func placeFields(r Receivable) string { + var names []string + for _, p := range r.Shape.Places { + names = append(names, p.Field) + } + return strings.Join(names, ", ") +} + +// holderTemplate is the holder's template for a kind, parsed. +func holderTemplate(holder Manifest, seat Seat, kind string) (*template.Template, error) { + for _, c := range holder.Claims { + if s, known := SeatNamed(c.Name); !known || s.Name != seat.Name { + continue + } + text, has := c.Renders[kind] + if !has || strings.TrimSpace(text) == "" { + break + } + t, err := template.New(seat.Name + ":" + kind).Funcs(renderFuncs).Parse(text) + if err != nil { + return nil, fmt.Errorf("%s's template for %s:%s does not parse: %v", holder.Module, seat.Name, kind, err) + } + return t, nil + } + return nil, fmt.Errorf("%s places %s:%s, which is data, and its claim gives no template for it in `renders` "+ + "(novox/hq ADR 0255)", holder.Module, seat.Name, kind) +} + +// renderPiece is one piece of data in the holder's grammar. The template sees the piece's fields and +// `module`, the contributor. A value the piece does not have reads as nothing in a `with` or an `if`; +// printed bare it would write `` into the tool's file, which is refused here instead. +func renderPiece(t *template.Template, module string, data map[string]any) (string, error) { + view := map[string]any{"module": module} + for k, v := range data { + view[k] = v + } + var b bytes.Buffer + if err := t.Execute(&b, view); err != nil { + return "", fmt.Errorf("rendering %s's piece: %v", module, err) + } + out := b.String() + if strings.Contains(out, "") { + return "", fmt.Errorf("rendering %s's piece printed a value it does not have: %q", module, out) + } + return out, nil +} + +// placedPiece is one contributed piece on its way into a holder's file. +type placedPiece struct { + module string + data map[string]any + order int +} + +// shapedContributions is every module's data of one kind to one seat, narrowed by where and by the +// machine's capabilities, ordered, and rendered through the holder's template — each piece under a +// comment line naming its module. +func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, error) { + var pieces []placedPiece + for _, m := range inModuleOrder(modules) { + for _, c := range m.allContributions() { + if c.Kind != r.Kind || !capable(c, caps) { + continue + } + if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name { + continue + } + match := true + for field, value := range where { + if v, _ := c.Data[field].(string); v != value { + match = false + } + } + if !match { + continue + } + order := OrderDefault + if v, ok := wholeIn(c.Data[r.Shape.Order], 0, 99); ok { + order = v + } + pieces = append(pieces, placedPiece{module: m.Module, data: c.Data, order: order}) + } + } + if len(pieces) == 0 { + return "", nil + } + t, err := holderTemplate(holder, s, r.Kind) + if err != nil { + return "", err + } + sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order }) + var b strings.Builder + for _, p := range pieces { + out, err := renderPiece(t, p.module, p.data) + if err != nil { + return "", err + } + fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module) + b.WriteString(out) + if !strings.HasSuffix(out, "\n") { + b.WriteString("\n") + } + } + return b.String(), nil +} + +// capable is whether a contribution applies on a machine with these capabilities: always, unless it +// names one with `if-capability` the machine did not report (novox/hq ADR 0255). A machine whose +// capabilities are not known has none, so a conditional piece is left out rather than guessed in. +func capable(c SeatContribution, caps map[string]bool) bool { + return c.IfCapability == "" || caps[c.IfCapability] +} + +// placementProblems is what is wrong with how a holder places the kinds its seats receive as data: +// once it places one at all, it needs a template that renders every example of the shape, and every combination of the placing +// fields placed exactly once across its files — or something contributed would be dropped, or written +// twice (novox/hq ADR 0255). +func (m Manifest) placementProblems() []string { + var problems []string + for _, c := range m.Claims { + s, known := SeatNamed(c.Name) + if !known { + continue + } + for _, r := range s.Receives { + // A holder that places none of the kind is older than it (novox/hq ADR 0255): refusing it + // would refuse every bar registered before the kind existed, and the controller that + // defines the kind ships first. Its contributions wait, unplaced, until it places them. + if r.Shape == nil || !placesKind(m, s, r.Kind) { + continue + } + t, err := holderTemplate(m, s, r.Kind) + if err != nil { + problems = append(problems, err.Error()) + continue + } + for _, ex := range r.Shape.Examples { + if out, err := renderPiece(t, "example", ex); err != nil || strings.TrimSpace(out) == "" { + problems = append(problems, fmt.Sprintf("%s's template for %s:%s renders nothing for %v: %v", + m.Module, s.Name, r.Kind, ex, err)) + } + } + placed := map[string]int{} + for _, res := range m.Resources { + content, _ := res["content"].(string) + for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { + seat, rest, _ := strings.Cut(f[1], ":") + kind, whereText, _ := strings.Cut(rest, ":") + if cs, ok := SeatNamed(seat); !ok || cs.Name != s.Name || kind != r.Kind { + continue + } + where, err := placeholderWhere(r, whereText) + if err != nil { + continue // said by seatPlaceholderProblems + } + for _, combo := range combinations(r.Shape.Places) { + if covers(where, combo) { + placed[comboKey(combo)]++ + } + } + } + } + for _, combo := range combinations(r.Shape.Places) { + switch n := placed[comboKey(combo)]; { + case n == 0: + problems = append(problems, fmt.Sprintf("%s never places %s:%s for %s; every combination of %s is "+ + "placed once, or a contribution to it is lost (novox/hq ADR 0255)", + m.Module, s.Name, r.Kind, comboKey(combo), placeFields(r))) + case n > 1: + problems = append(problems, fmt.Sprintf("%s places %s:%s for %s %d times; once, or a contribution "+ + "is written twice (novox/hq ADR 0255)", m.Module, s.Name, r.Kind, comboKey(combo), n)) + } + } + } + } + return problems +} + +// placesKind is whether any of a holder's files names the kind's placeholder. +func placesKind(m Manifest, s Seat, kind string) bool { + for _, res := range m.Resources { + content, _ := res["content"].(string) + for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { + seat, rest, _ := strings.Cut(f[1], ":") + k, _, _ := strings.Cut(rest, ":") + if cs, ok := SeatNamed(seat); ok && cs.Name == s.Name && k == kind { + return true + } + } + } + return false +} + +// combinations is every assignment of a value to each placing field. +func combinations(places []PlaceField) []map[string]string { + out := []map[string]string{{}} + for _, p := range places { + var next []map[string]string + for _, partial := range out { + for _, v := range p.Values { + c := map[string]string{p.Field: v} + for k, w := range partial { + c[k] = w + } + next = append(next, c) + } + } + out = next + } + return out +} + +func covers(where, combo map[string]string) bool { + for field, value := range where { + if combo[field] != value { + return false + } + } + return true +} + +func comboKey(combo map[string]string) string { + var parts []string + for _, k := range sortedKeys(combo) { + parts = append(parts, k+"="+combo[k]) + } + return strings.Join(parts, ",") +} diff --git a/internal/catalogue/seat_data_test.go b/internal/catalogue/seat_data_test.go new file mode 100644 index 00000000..f6a537ec --- /dev/null +++ b/internal/catalogue/seat_data_test.go @@ -0,0 +1,203 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// Defends novox/hq ADR 0255: a seat may receive a kind as data in its own shape, which the holder +// renders with its template; the holder places every combination of the shape's placing fields once; +// a contribution may be placed only where the machine reports a capability; and an offered kind makes +// no dependency on its seat. + +const barTemplate = `{{if eq .shows "battery"}}[[block]] +block = "battery" +{{with .options}}{{with .device}}device = {{quote .}} +{{end}}{{end}}{{else if eq .shows "command"}}[[block]] +block = "custom" +command = {{quote .options.command}} +interval = {{.options.interval}} +{{end}}` + +func barHolder() Manifest { + place := func(bar string) string { + return "own-first\n${contribution:node-bar:block:bar=" + bar + ",place=resources}" + + "own-middle\n${contribution:node-bar:block:bar=" + bar + ",place=status}own-last\n" + } + return Manifest{Module: "a-bar", Claims: []Claim{{Name: BarSeat, Renders: map[string]string{BarKindBlock: barTemplate}}}, + Resources: []map[string]any{ + {"id": "bottom", "type": "file", "path": "/b", "content": place("bottom")}, + {"id": "top", "type": "file", "path": "/t", "content": place("top")}, + }} +} + +func block(data string) SeatContribution { + var d map[string]any + if err := json.Unmarshal([]byte(data), &d); err != nil { + panic(err) + } + return SeatContribution{Seat: BarSeat, Kind: BarKindBlock, Data: d} +} + +func composedFile(t *testing.T, r Resolution, id string) string { + t.Helper() + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == id { + return res["content"].(string) + } + } + t.Fatalf("%s was not composed", id) + return "" +} + +func TestABlockIsRenderedByTheHolderInItsPlaceAndOrderNamedByModule(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"draw \"now\"","interval":5}}`), + }} + other := Manifest{Module: "another", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":70,"shows":"battery","options":{"device":"BAT1"}}`), + block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":60}}`), + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power, other}} + + // Ordered by order (20, 50, 70), then module order; each piece under its module's name; the + // command quoted so its own quotes cannot end the line. + want := "own-first\nown-middle\n" + + "# power\n[[block]]\nblock = \"custom\"\ncommand = \"draw \\\"now\\\"\"\ninterval = 5\n" + + "# power\n[[block]]\nblock = \"battery\"\n" + + "# another\n[[block]]\nblock = \"battery\"\ndevice = \"BAT1\"\n" + + "own-last\n" + if got := composedFile(t, r, "a-bar.bottom"); got != want { + t.Fatalf("the bottom bar is\n%s\nnot\n%s", got, want) + } + want = "own-first\n# another\n[[block]]\nblock = \"custom\"\ncommand = \"up\"\ninterval = 60\nown-middle\nown-last\n" + if got := composedFile(t, r, "a-bar.top"); got != want { + t.Fatalf("the top bar is\n%s\nnot\n%s", got, want) + } +} + +func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + func() SeatContribution { + c := block(`{"bar":"bottom","place":"status","shows":"battery"}`) + c.IfCapability = "battery" + return c + }(), + {Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_BATTERY 1 x", IfCapability: "battery"}, + }} + for _, c := range []struct { + caps map[string]bool + has bool + }{{map[string]bool{"battery": true}, true}, {map[string]bool{"seat": true}, false}, {nil, false}} { + r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), hotkeysHolder(), power}} + bar, keys := composedFile(t, r, "a-bar.bottom"), composedFile(t, r, "triggerhappy.triggers") + if strings.Contains(bar, `block = "battery"`) != c.has || strings.Contains(keys, "KEY_BATTERY") != c.has { + t.Errorf("with %v: the battery placed is not %v:\n%s\n%s", c.caps, c.has, bar, keys) + } + } +} + +func TestABlockOutsideTheShapeIsRefused(t *testing.T) { + cases := map[string]string{ + `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data", + `{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content", + `{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text", + `{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds", + `{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability", + } + for c, want := range cases { + raw := `{"module":"power","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) + } + } + ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` + + `"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("a block in the shape was refused: %v", err) + } +} + +func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) { + holder := func(template string, contents ...string) string { + var res []string + for i, c := range contents { + b, _ := json.Marshal(c) + res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`) + } + tb, _ := json.Marshal(template) + renders := "" + if template != "" { + renders = `,"renders":{"block":` + string(tb) + `}` + } + return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}` + } + all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" + + "${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}" + cases := map[string]string{ + holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources", + holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times", + holder("", all): "gives no template", + holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing", + holder(`{{.nothing}}`, all): "renders nothing", + holder(`{{if}`, all): "does not parse", + holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top", + holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place", + holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err) + } + } + for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"), + // A holder that places no block at all is older than the kind, and is not refused for it. + holder("", "no blocks here")} { + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Errorf("%s: refused: %v", raw, err) + } + } +} + +func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, + }} + for _, seat := range DependsOn(power) { + if seat == BarSeat { + t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(power)) + } + } + // And on a machine with no bar it is placed nowhere, and nothing fails. + r := Resolution{Node: "server", Account: "op", Capabilities: map[string]bool{"battery": true}, + Modules: []Manifest{hotkeysHolder(), power}} + if _, err := r.Declaration(Rendering{}); err != nil { + t.Fatal(err) + } +} + +func TestTheBarReceivesBlocksAsData(t *testing.T) { + s, _ := SeatNamed(BarSeat) + if len(s.Receives) != 1 || s.Receives[0].Kind != BarKindBlock || s.Receives[0].Shape == nil || !s.Receives[0].Offered { + t.Fatalf("node-bar receives %+v", s.Receives) + } + for _, ex := range s.Receives[0].Shape.Examples { + if p := barBlockProblems(ex); len(p) > 0 { + t.Errorf("the shape's own example %v is refused: %v", ex, p) + } + } +} diff --git a/internal/catalogue/seat_dependencies.go b/internal/catalogue/seat_dependencies.go index dc2e0f0c..2acb6ec5 100644 --- a/internal/catalogue/seat_dependencies.go +++ b/internal/catalogue/seat_dependencies.go @@ -118,8 +118,11 @@ func contributedTo(m Manifest) []string { } // 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. + // A kind the seat only offers to place makes no dependency (novox/hq ADR 0255). for _, c := range m.Contributions { - if s, known := SeatNamed(c.Seat); known { + if s, r, ok := receivable(c.Seat, c.Kind); ok && r.Offered { + continue + } else if s.Name != "" { out = append(out, s.Name) } } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 82c519cb..01643dd5 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -68,6 +68,13 @@ type Receivable struct { // only the mesh knows where. Off for every kind written in a tool's grammar that has its own // `${…}` — a shell's — where the mesh filling one would change what the tool reads. Dirs bool + // Shape, when set, makes the kind data rather than text (novox/hq ADR 0255): a contributor gives + // `data` in this shape, and the holder renders each piece with the template its claim gives. + Shape *Shape + // Offered says a contribution of this kind does not depend on the seat (novox/hq ADR 0255): where + // nobody holds it nothing is placed, and the contributor runs there all the same. A battery's bar + // block is offered — the power module is on every machine, and a server has no bar. + Offered bool } // defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the