Merge pull request 'Let a seat receive blocks as data its holder renders, placed where the machine has the hardware (hq ADR 0255)' (#143) from feat/the-bar-takes-blocks into main

This commit was merged in pull request #143.
This commit is contained in:
2026-10-08 13:31:48 +00:00
16 changed files with 988 additions and 37 deletions
+8
View File
@@ -135,6 +135,7 @@ type mergeComposed struct {
Problems []string `json:"problems,omitempty"` Problems []string `json:"problems,omitempty"`
Withheld []string `json:"withheld,omitempty"` Withheld []string `json:"withheld,omitempty"`
Unbound []string `json:"unbound,omitempty"` Unbound []string `json:"unbound,omitempty"`
Unplaced []string `json:"unplaced,omitempty"`
LeftOut map[string]string `json:"left-out,omitempty"` LeftOut map[string]string `json:"left-out,omitempty"`
Resources []string `json:"resources,omitempty"` Resources []string `json:"resources,omitempty"`
} }
@@ -372,6 +373,12 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s", v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s",
gm.Described, u)) gm.Described, u))
} }
// A contribution its holder's template renders nothing for (novox/hq ADR 0255): the machine
// would be sent without it, so a change that adds one is refused, naming it.
for _, u := range newOnly(gm.Change.Unplaced, gm.Base.Unplaced) {
v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a contribution unplaced — %s",
gm.Described, u))
}
for module, why := range gm.Change.LeftOut { for module, why := range gm.Change.LeftOut {
if _, was := gm.Base.LeftOut[module]; !was { if _, was := gm.Base.LeftOut[module]; !was {
v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s", v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s",
@@ -780,6 +787,7 @@ func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[stri
for _, u := range declared.unbound { for _, u := range declared.unbound {
c.Unbound = append(c.Unbound, u.String()) c.Unbound = append(c.Unbound, u.String())
} }
c.Unplaced = declared.unplaced
sort.Strings(c.Withheld) sort.Strings(c.Withheld)
sort.Strings(c.Unbound) sort.Strings(c.Unbound)
out[m.Name] = c out[m.Name] = c
+6 -1
View File
@@ -419,7 +419,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
out := sendable{Resources: composed.Resources, Adoption: adoption, out := sendable{Resources: composed.Resources, Adoption: adoption,
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld, LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
unbound: with.Unbound, foreseen: composed.Foreseen} unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced}
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR // And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
// 0221). Read only on the send path: a question about what would be sent records nothing. // 0221). Read only on the send path: a question about what would be sent records nothing.
if choosing == Allocating { if choosing == Allocating {
@@ -528,6 +528,11 @@ func reportLeftOut(node string, declared sendable) {
for _, u := range declared.unbound { for _, u := range declared.unbound {
fmt.Printf("%s: %s\n", node, u) fmt.Printf("%s: %s\n", node, u)
} }
// And every contribution its holder could not render, which the machine is sent without (novox/hq
// ADR 0255).
for _, u := range declared.unplaced {
fmt.Printf("%s: %s\n", node, u)
}
} }
// busCredentialIssued refuses an own secret called `broker` whose bus account nobody issued. // busCredentialIssued refuses an own secret called `broker` whose bus account nobody issued.
+3
View File
@@ -58,6 +58,9 @@ type sendable struct {
// make (Foreseeing, novox/hq issue 275). Never on the wire, and a declaration that has any is never // make (Foreseeing, novox/hq issue 275). Never on the wire, and a declaration that has any is never
// sent. // sent.
foreseen []string foreseen []string
// unplaced is every contribution its holder's template could not render, naming its module and
// why (novox/hq ADR 0255): left out of this declaration, for push and plan to say, never on the wire.
unplaced []string
// Builds is the build of each module this declaration carries — module to the commit its build // Builds is the build of each module this declaration carries — module to the commit its build
// was made from — recorded with the send and never on the wire (novox/hq issue 259, ADR 0221). // was made from — recorded with the send and never on the wire (novox/hq issue 259, ADR 0221).
// Composed only on the send path; nil records that it is not known. // Composed only on the send path; nil records that it is not known.
+1 -1
View File
@@ -32,7 +32,7 @@ func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+3 -3
View File
@@ -174,7 +174,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
"resources":[{"id":"meta","type":"directory","mode":"0700"}]}`) "resources":[{"id":"meta","type":"directory","mode":"0700"}]}`)
facts := map[string]string{"account-home": "/home/op"} 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"}}}}}} 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -182,7 +182,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
if backup != want { if backup != want {
t.Fatalf("backup lines:\n%s\nwant:\n%s", 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -192,7 +192,7 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
if data != want { if data != want {
t.Fatalf("data lines:\n%s\nwant:\n%s", 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") 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 // What keeps something irreplaceable depends on the machine's backup holder — it backs it up or
+10 -5
View File
@@ -291,6 +291,10 @@ type Composed struct {
// as `module/name`, sorted: what the next send will make. Never set on a declaration that is // as `module/name`, sorted: what the next send will make. Never set on a declaration that is
// sent — a placeholder in a sealed file is a credential the process cannot read. // sent — a placeholder in a sealed file is a credential the process cannot read.
Foreseen []string Foreseen []string
// Unplaced is every contribution the holder's template could not render into something, each
// naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told
// everything else. Push and plan say it; the merge gate refuses a change that adds one.
Unplaced []string
} }
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of // ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
@@ -326,8 +330,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
owner := map[string]string{} owner := map[string]string{}
received := map[string]map[string][]Contribution{} received := map[string]map[string][]Contribution{}
leftOut := map[string]string{} leftOut := map[string]string{}
var foreseen []string var foreseen, unplaced []string
resources, err := r.compose(with, owner, received, leftOut, &foreseen) resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced)
if err != nil { if err != nil {
return Composed{}, err return Composed{}, err
} }
@@ -340,8 +344,9 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
"sealed": with.BusMembership, "mode": "0600", "sealed": with.BusMembership, "mode": "0600",
}) })
} }
sort.Strings(unplaced)
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
Foreseen: foreseen}, nil Foreseen: foreseen, Unplaced: unplaced}, nil
} }
// BusMembershipID names the resource carrying a machine's membership for the new bus, and // BusMembershipID names the resource carrying a machine's membership for the new bus, and
@@ -352,7 +357,7 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json"
func (r Resolution) compose(with Rendering, owner map[string]string, func (r Resolution) compose(with Rendering, owner map[string]string,
received map[string]map[string][]Contribution, leftOut map[string]string, received map[string]map[string][]Contribution, leftOut map[string]string,
foreseen *[]string) ([]map[string]any, error) { foreseen, unplaced *[]string) ([]map[string]any, error) {
// **A setting is judged where it is stored, and an impossible one costs a module, not a // **A setting is judged where it is stored, and an impossible one costs a module, not a
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
// this module uncomposable; it is left out of the declaration — its held things kept, its // this module uncomposable; it is left out of the declaration — its held things kept, its
@@ -970,7 +975,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 // 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 // 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. // 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, unplaced); err != nil {
return nil, err return nil, err
} }
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
+5 -2
View File
@@ -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 // `${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 // 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. // 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, unplaced *[]string) error {
if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 { if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 {
return fmt.Errorf("%s", problems[0]) return fmt.Errorf("%s", problems[0])
} }
@@ -576,10 +576,13 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest,
found := ofContributed.FindStringSubmatch(placeholder) found := ofContributed.FindStringSubmatch(placeholder)
first, second, _ := strings.Cut(found[2], ":") first, second, _ := strings.Cut(found[2], ":")
if found[1] == "contribution" { if found[1] == "contribution" {
placed, err := seatContributions(modules, first, second, with, facts) placed, left, err := seatContributions(modules, m, found[2], with, facts, caps)
if err != nil && failed == nil { if err != nil && failed == nil {
failed = err failed = err
} }
if unplaced != nil {
*unplaced = append(*unplaced, left...)
}
return placed return placed
} }
return shellCode(modules, first, second) return shellCode(modules, first, second)
+14 -13
View File
@@ -177,19 +177,8 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
// wrong on every node whose assignment differs, and wrong for a second reason on a node given the // wrong on every node whose assignment differs, and wrong for a second reason on a node given the
// port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves // port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves
// in an `env` at all is a declaration, not a manifest. // in an `env` at all is a declaration, not a manifest.
// forgeBuilt is every artifact the forge's manifest builds: its code, and since hq ADR 0251 the npm
// registry it serves beside it. A manifest resolves only against all of what it asked to be built.
func forgeBuilt() []Built {
var out []Built
for _, name := range []string{"code", "npm-registry"} {
out = append(out, Built{Name: name, Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "gitea/" + name + "/blobs/" + bundleDigest, Digest: bundleDigest})
}
return out
}
func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt()) forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt(catalogueManifest(t, "gitea")))
if err != nil { if err != nil {
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
} }
@@ -242,7 +231,7 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any { func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
t.Helper() t.Helper()
forge := catalogueManifest(t, "gitea") forge := catalogueManifest(t, "gitea")
resolved, err := forge.Resolve(forgeBuilt()) resolved, err := forge.Resolve(forgeBuilt(forge))
if err != nil { if err != nil {
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
} }
@@ -314,3 +303,15 @@ func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) {
t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"]) t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"])
} }
} }
// forgeBuilt is a build of every artifact the forge's manifest declares: its code bundle and, since the
// catalogue added it (novox/hq ADR 0251 §4), its npm registry's. Named from the manifest, so a third
// artifact does not break every forge test again.
func forgeBuilt(forge Manifest) []Built {
var out []Built
for _, a := range forge.Build.Artifacts {
out = append(out, Built{Name: a.Name, Kind: ArtifactBundle,
Reference: ArtifactStoreScheme + "gitea/" + a.Name + "/blobs/" + bundleDigest, Digest: bundleDigest})
}
return out
}
+100 -2
View File
@@ -1,5 +1,10 @@
package catalogue package catalogue
import (
"fmt"
"strings"
)
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's // 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 // 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 // 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 // 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. // service are roles a second holder competes for, and nothing has needed to ask them anything.
func graphicalSessionSeats() []Seat { func graphicalSessionSeats() []Seat {
const decided = "novox/hq ADR 0208" const decided = "novox/hq ADR 0208"
@@ -93,7 +99,11 @@ func graphicalSessionSeats() []Seat {
{Name: "copy", Description: "Put text on the operator's clipboard.", {Name: "copy", Description: "Put text on the operator's clipboard.",
Input: schema(map[string]string{"text": "the text"}, []string{"text"})}, 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: CompositorSeat, Scope: ScopeNode, Decision: decided},
{Name: SecretServiceSeat, 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 p["enum"] = values
return s 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
}
+5
View File
@@ -60,6 +60,10 @@ type Claim struct {
// module's own `tools` must list every verb the seat promises, which is how a module named // 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. // like its seat — the catalogue, the records — says they are one and the same.
Serves []string `json:"serves,omitempty"` 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 // 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.shellProblems()...)
problems = append(problems, m.contributionPlaceholderProblems()...) problems = append(problems, m.contributionPlaceholderProblems()...)
problems = append(problems, m.seatContributionProblems()...) problems = append(problems, m.seatContributionProblems()...)
problems = append(problems, m.placementProblems()...)
problems = append(problems, m.dataProblems()...) problems = append(problems, m.dataProblems()...)
problems = append(problems, m.upgradeProblems()...) problems = append(problems, m.upgradeProblems()...)
+5 -1
View File
@@ -134,6 +134,10 @@ type Resolution struct {
// here without a store lookup. // here without a store lookup.
Account string Account string
AccountHome 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 in the order they were resolved: assigned first, then what they pulled in.
Modules []Manifest 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, 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} Because: because, Needs: needs, Unhostable: unhostable, Kept: kept}
for _, n := range providersFirst(order, catalogue) { for _, n := range providersFirst(order, catalogue) {
resolution.Modules = append(resolution.Modules, catalogue[n]) resolution.Modules = append(resolution.Modules, catalogue[n])
+61 -8
View File
@@ -30,7 +30,14 @@ type SeatContribution struct {
// Kind is which of the seat's receivable kinds it is. // Kind is which of the seat's receivable kinds it is.
Kind string `json:"kind"` Kind string `json:"kind"`
// Content is the text, in the tool's own grammar. Never interpreted. // 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:<seat>:<kind>}. Loose inside the // ofContribution is where a holder places a kind: ${contribution:<seat>:<kind>}. Loose inside the
@@ -92,7 +99,30 @@ func (m Manifest) seatContributionProblems() []string {
"%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)", "%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))) m.Module, i+1, s.Name, c.Kind, kindsOf(s)))
} }
if strings.TrimSpace(c.Content) == "" { // Only on a kind the seat offers (novox/hq ADR 0255): a piece shown where the hardware is. On a
// kind a holder depends on — what a backup keeps, a key's trigger — a machine missing the
// capability would lose the piece without a word.
if c.IfCapability != "" {
switch {
case !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))
case !oneOf(KnownCapabilities, c.IfCapability):
problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which no machine "+
"reports; the node-engine detects %s", m.Module, i+1, c.IfCapability, strings.Join(KnownCapabilities, ", ")))
case ok && !r.Offered:
problems = append(problems, fmt.Sprintf("%s's contribution %d to %s (%s) is if-capability %s; only a "+
"kind the seat offers may be left out where a machine lacks something (novox/hq ADR 0255)",
m.Module, i+1, s.Name, c.Kind, 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)) problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1))
} }
} }
@@ -120,8 +150,15 @@ func seatPlaceholderProblems(m Manifest, r map[string]any) []string {
continue continue
} }
for _, f := range found { for _, f := range found {
seat, kind, two := strings.Cut(f[1], ":") seat, rest, two := strings.Cut(f[1], ":")
s, _, ok := receivable(seat, kind) // 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 { if !two || !ok {
detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat) detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat)
if s.Name != "" { if s.Name != "" {
@@ -152,17 +189,33 @@ 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 // 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 // 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). // 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.
//
// What it could not render is answered as unplaced, each naming its module and why, and left out: one
// piece the holder's template does not know is that piece's fault, not the machine's.
func seatContributions(modules []Manifest, holder Manifest, placeholder string, with Rendering, facts map[string]string, caps map[string]bool) (string, []string, error) {
seat, rest, _ := strings.Cut(placeholder, ":")
kind, whereText, _ := strings.Cut(rest, ":")
s, r, ok := receivable(seat, kind) s, r, ok := receivable(seat, kind)
if !ok { if !ok {
return "", nil return "", nil, nil
}
if r.Shape != nil {
where, err := placeholderWhere(r, whereText)
if err != nil {
return "", nil, err
}
return shapedContributions(modules, holder, s, r, where, caps)
} }
var failed error var failed error
var b strings.Builder var b strings.Builder
for _, m := range inModuleOrder(modules) { for _, m := range inModuleOrder(modules) {
named := false named := false
for _, c := range m.allContributions() { for _, c := range m.allContributions() {
if c.Kind != kind { if c.Kind != kind || !capable(c, caps) {
continue continue
} }
if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name { if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name {
@@ -207,5 +260,5 @@ func seatContributions(modules []Manifest, seat, kind string, with Rendering, fa
} }
} }
} }
return b.String(), failed return b.String(), nil, failed
} }
+402
View File
@@ -0,0 +1,402 @@
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:<seat>:<kind>:<field>=<value>,…}`, 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 the shape of a capability's name, as a node's profile reports it.
var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// KnownCapabilities is every capability the node-engine detects (mesh-host internal/profile), the
// names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its
// contribution out on every machine, silently, so it is refused. A detector added there is added here.
var KnownCapabilities = []string{
"battery", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager",
"power-meter", "privileged", "seat", "service-manager", "uplink-dhcpcd", "uplink-networkmanager",
"uplink-systemd-networkd", "virtualisation",
}
// renderFuncs are the functions a holder's template may call. `quote` writes a value as a JSON
// string with DEL escaped as well — JSON leaves it bare, and TOML refuses it — which makes it a valid
// basic string in TOML and in most tools' grammars, so a contributed command with quotes or control
// characters in it cannot break out of the holder's line.
var renderFuncs = template.FuncMap{"quote": quote}
func quote(v any) (string, error) {
b, err := json.Marshal(fmt.Sprint(v))
return strings.ReplaceAll(string(b), "\x7f", `\u007f`), 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 `<no value>` 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, "<no value>") {
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.
//
// A piece the template renders to nothing — a `shows` the holder does not know yet — or fails on is
// left out and answered as unplaced, naming its module: the other pieces and the rest of the machine's
// declaration go on (novox/hq ADR 0255).
func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []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, nil
}
t, err := holderTemplate(holder, s, r.Kind)
if err != nil {
return "", nil, err
}
sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order })
var b strings.Builder
var unplaced []string
for _, p := range pieces {
out, err := renderPiece(t, p.module, p.data)
if err == nil && strings.TrimSpace(out) == "" {
err = fmt.Errorf("%s's template renders nothing for it", holder.Module)
}
if err != nil {
unplaced = append(unplaced, fmt.Sprintf("%s's %s:%s %s is not placed by %s: %v",
p.module, s.Name, r.Kind, describePiece(p.data), holder.Module, err))
continue
}
fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module)
b.WriteString(out)
if !strings.HasSuffix(out, "\n") {
b.WriteString("\n")
}
}
return b.String(), unplaced, nil
}
// describePiece is a piece in one line, its fields in order, for a person reading why it was left out.
func describePiece(data map[string]any) string {
var parts []string
for _, k := range sortedKeys(data) {
if _, nested := data[k].(map[string]any); nested {
continue
}
parts = append(parts, fmt.Sprintf("%s=%v", k, data[k]))
}
return "(" + strings.Join(parts, " ") + ")"
}
// 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, ",")
}
+354
View File
@@ -0,0 +1,354 @@
package catalogue
import (
"encoding/json"
"fmt"
"strconv"
"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) {
c := block(`{"bar":"bottom","place":"status","shows":"battery"}`)
c.IfCapability = "battery"
power := Manifest{Module: "power", Contributions: []SeatContribution{c}}
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(), power}}
if bar := composedFile(t, r, "a-bar.bottom"); strings.Contains(bar, `block = "battery"`) != c.has {
t.Errorf("with %v: the battery placed is not %v:\n%s", c.caps, c.has, bar)
}
}
}
func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) {
cases := map[string]string{
// A name nothing detects would leave the piece out on every machine.
`{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports",
// A kind the holder depends on is never left out for want of something.
`{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers",
`{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers",
}
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)
}
}
for _, name := range []string{"battery", "power-meter", "seat"} {
if !oneOf(KnownCapabilities, name) {
t.Errorf("%s is not a known capability", name)
}
}
}
func TestResolveCarriesTheMachinesCapabilities(t *testing.T) {
node := workstation()
node.Capabilities["battery"] = true
r, err := Resolve(shelf(Manifest{Module: "a"}), []string{"a"}, node, World{})
if err != nil {
t.Fatal(err)
}
if !r.Capabilities["battery"] || !r.Capabilities["seat"] {
t.Fatalf("the resolution carries %v, not the node's %v", r.Capabilities, node.Capabilities)
}
}
func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) {
// A shows the shape gained after the holder's template was written: built here, past the check.
power := Manifest{Module: "power", Contributions: []SeatContribution{
block(`{"bar":"bottom","place":"status","shows":"gpu"}`),
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
}}
r := Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), power}}
c, err := r.Compose(Rendering{})
if err != nil {
t.Fatalf("one piece failed the whole machine: %v", err)
}
if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "power's node-bar:block") ||
!strings.Contains(c.Unplaced[0], "shows=gpu") || !strings.Contains(c.Unplaced[0], "renders nothing") {
t.Fatalf("unplaced: %v", c.Unplaced)
}
for _, res := range c.Resources {
if res["id"] == "a-bar.bottom" && !strings.Contains(res["content"].(string), `block = "battery"`) {
t.Fatalf("the piece beside it was lost too: %s", res["content"])
}
}
}
func TestTheCataloguesBarRendersEveryExampleOfTheShape(t *testing.T) {
holder := catalogueManifest(t, "i3status-rust")
s, _ := SeatNamed(BarSeat)
if !placesKind(holder, s, BarKindBlock) {
t.Skip("the catalogue beside this checkout has a bar that places no blocks yet")
}
tmpl, err := holderTemplate(holder, s, BarKindBlock)
if err != nil {
t.Fatal(err)
}
for _, ex := range s.Receives[0].Shape.Examples {
out, err := renderPiece(tmpl, "example", ex)
if err != nil || !strings.Contains(out, "[[block]]") {
t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err)
}
}
for shows := range barShows {
covered := false
for _, ex := range s.Receives[0].Shape.Examples {
covered = covered || ex["shows"] == shows
}
if !covered {
t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows)
}
}
}
func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) {
for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} {
q, err := quote(in)
if err != nil {
t.Fatal(err)
}
got, err := tomlBasicString(q)
if err != nil || got != in {
t.Errorf("quote(%q) = %s, which TOML reads as %q: %v", in, q, got, err)
}
}
}
// tomlBasicString reads one TOML basic string, as the TOML specification (1.0, "String") defines it:
// any character but a quote, a backslash and the control characters U+0000–U+0008, U+000A–U+001F
// and U+007F, which are written as escapes.
func tomlBasicString(s string) (string, error) {
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
return "", fmt.Errorf("not quoted")
}
var b strings.Builder
rs := []rune(s[1 : len(s)-1])
for i := 0; i < len(rs); i++ {
r := rs[i]
switch {
case r == '"':
return "", fmt.Errorf("a bare quote at %d", i)
case r == 0x7f || (r < 0x20 && r != '\t'):
return "", fmt.Errorf("a bare control character %U at %d", r, i)
case r != '\\':
b.WriteRune(r)
continue
}
i++
if i >= len(rs) {
return "", fmt.Errorf("an escape at the end")
}
switch rs[i] {
case 'b':
b.WriteRune('\b')
case 't':
b.WriteRune('\t')
case 'n':
b.WriteRune('\n')
case 'f':
b.WriteRune('\f')
case 'r':
b.WriteRune('\r')
case '"':
b.WriteRune('"')
case '\\':
b.WriteRune('\\')
case 'u', 'U':
n := 4
if rs[i] == 'U' {
n = 8
}
if i+n > len(rs)-1 {
return "", fmt.Errorf("a short \\u escape")
}
v, err := strconv.ParseUint(string(rs[i+1:i+1+n]), 16, 32)
if err != nil {
return "", err
}
b.WriteRune(rune(v))
i += n
default:
return "", fmt.Errorf("the escape \\%c is not TOML's", rs[i])
}
}
return b.String(), nil
}
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)
}
}
}
+4 -1
View File
@@ -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 // 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. // 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 { 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) out = append(out, s.Name)
} }
} }
+7
View File
@@ -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 // 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. // `${…}` — a shell's — where the mesh filling one would change what the tool reads.
Dirs bool 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 // defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the