diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go index 43663e87..bbccbfb7 100644 --- a/internal/catalogue/graphical_session.go +++ b/internal/catalogue/graphical_session.go @@ -129,6 +129,23 @@ var barShows = map[string]map[string]barOption{ "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}}, + // A value the contributor keeps on the bus (novox/hq ADR 0255): its state `.` and the + // key, this machine's name when absent. The value is an object whose `text` is what is shown. The + // holder is granted to read it and shows it however its bar shows a changing value; no file and + // no command passes between the two modules. + "state": {"state": {kind: "text", required: true}, "key": {kind: "text"}}, +} + +// barBlockReads is the state a block shows, if any. +func barBlockReads(data map[string]any) []string { + if data["shows"] != "state" { + return nil + } + options, _ := data["options"].(map[string]any) + if s, ok := options["state"].(string); ok && s != "" { + return []string{s} + } + return nil } type barOption struct { @@ -150,11 +167,15 @@ func barBlockShape() *Shape { {Field: "place", Values: []string{"resources", "status"}}}, Order: "order", Check: barBlockProblems, + Reads: barBlockReads, 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)}}, + {"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}}, + {"bar": "bottom", "place": "status", "shows": "state", + "options": map[string]any{"state": "power.draw", "key": "a-machine"}}, }, } } diff --git a/internal/catalogue/seat_contributions.go b/internal/catalogue/seat_contributions.go index 9a408bc5..594e4de8 100644 --- a/internal/catalogue/seat_contributions.go +++ b/internal/catalogue/seat_contributions.go @@ -119,6 +119,7 @@ func (m Manifest) seatContributionProblems() []string { switch { case ok && r.Shape != nil: problems = append(problems, shapedProblems(m.Module, i, r, c)...) + problems = append(problems, ownStateProblems(m, 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)) @@ -208,7 +209,7 @@ func seatContributions(modules []Manifest, holder Manifest, placeholder string, if err != nil { return "", nil, err } - return shapedContributions(modules, holder, s, r, where, caps) + return shapedContributions(modules, holder, facts["name"], s, r, where, caps) } var failed error var b strings.Builder diff --git a/internal/catalogue/seat_data.go b/internal/catalogue/seat_data.go index 4b09b751..e09c77ce 100644 --- a/internal/catalogue/seat_data.go +++ b/internal/catalogue/seat_data.go @@ -38,6 +38,10 @@ type Shape struct { // 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 + // Reads is the state on the bus a piece shows, as `.`, or nothing (novox/hq ADR + // 0255): a contributor offers only state it keeps itself, and the holder on the same machine is + // granted to read it, so the holder's manifest names no contributor. + Reads func(data map[string]any) []string `json:"-"` } // PlaceField is one field a holder places by, and the values it takes. @@ -105,6 +109,62 @@ func shapedProblems(module string, i int, r Receivable, c SeatContribution) []st return problems } +// ownStateProblems is a data contribution showing state its module does not keep (novox/hq ADR 0255): +// a module offers the holder its own state and nobody else's, so what it publishes stays its own. +func ownStateProblems(m Manifest, i int, r Receivable, c SeatContribution) []string { + if r.Shape == nil || r.Shape.Reads == nil { + return nil + } + keeps := map[string]bool{} + for _, s := range m.State { + keeps[s.Name] = true + } + var problems []string + for _, read := range r.Shape.Reads(c.Data) { + module, local, err := ReadState(read) + switch { + case err != nil: + problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err)) + case module != m.Module || !keeps[local]: + problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+ + "module offers its own state only (novox/hq ADR 0255)", m.Module, i+1, read, m.Module)) + } + } + return problems +} + +// ReadsGranted is the state a holder reads because modules on its machine offer it in pieces of a +// kind its seat receives as data (novox/hq ADR 0255), sorted and once each. The bus grants the holder +// these beside its own `reads`: the holder names no contributor, and reads only what is offered to it +// on the machine it runs on. +func ReadsGranted(holder Manifest, onMachine []Manifest) []string { + seen := map[string]bool{} + for _, claim := range holder.Claims { + s, known := SeatNamed(claim.Name) + if !known { + continue + } + for _, r := range s.Receives { + if r.Shape == nil || r.Shape.Reads == nil { + continue + } + for _, m := range onMachine { + for _, c := range m.allContributions() { + if cs, ok := SeatNamed(c.Seat); !ok || cs.Name != s.Name || c.Kind != r.Kind { + continue + } + for _, read := range r.Shape.Reads(c.Data) { + if module, _, err := ReadState(read); err == nil && module == m.Module && module != holder.Module { + seen[read] = true + } + } + } + } + } + } + return sortedKeys(seen) +} + // 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) @@ -178,11 +238,11 @@ func holderTemplate(holder Manifest, seat Seat, kind string) (*template.Template "(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`; +// renderPiece is one piece of data in the holder's grammar. The template sees the piece's fields, +// `module`, the contributor, and `machine`, the node it is composed for. 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} +func renderPiece(t *template.Template, module, machine string, data map[string]any) (string, error) { + view := map[string]any{"module": module, "machine": machine} for k, v := range data { view[k] = v } @@ -211,7 +271,7 @@ type placedPiece struct { // 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) { +func shapedContributions(modules []Manifest, holder Manifest, machine string, 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() { @@ -248,7 +308,7 @@ func shapedContributions(modules []Manifest, holder Manifest, s Seat, r Receivab var b strings.Builder var unplaced []string for _, p := range pieces { - out, err := renderPiece(t, p.module, p.data) + out, err := renderPiece(t, p.module, machine, p.data) if err == nil && strings.TrimSpace(out) == "" { err = fmt.Errorf("%s's template renders nothing for it", holder.Module) } @@ -309,7 +369,7 @@ func (m Manifest) placementProblems() []string { continue } for _, ex := range r.Shape.Examples { - if out, err := renderPiece(t, "example", ex); err != nil || strings.TrimSpace(out) == "" { + if out, err := renderPiece(t, "example", "machine", 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)) } diff --git a/internal/catalogue/seat_data_test.go b/internal/catalogue/seat_data_test.go index a67186e8..a154ff90 100644 --- a/internal/catalogue/seat_data_test.go +++ b/internal/catalogue/seat_data_test.go @@ -20,6 +20,9 @@ block = "battery" block = "custom" command = {{quote .options.command}} interval = {{.options.interval}} +{{else if eq .shows "state"}}[[block]] +block = "custom" +command = {{quote (printf "show %s %s" .options.state (or .options.key .machine))}} {{end}}` func barHolder() Manifest { @@ -165,7 +168,7 @@ func TestTheCataloguesBarRendersEveryExampleOfTheShape(t *testing.T) { t.Fatal(err) } for _, ex := range s.Receives[0].Shape.Examples { - out, err := renderPiece(tmpl, "example", ex) + out, err := renderPiece(tmpl, "example", "a-machine", ex) if err != nil || !strings.Contains(out, "[[block]]") { t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err) } @@ -352,3 +355,41 @@ func TestTheBarReceivesBlocksAsData(t *testing.T) { } } } + +func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *testing.T) { + power := Manifest{Module: "power", State: []StateDeclaration{{Name: "draw"}}, Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`), + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power}} + if bar := composedFile(t, r, "a-bar.bottom"); !strings.Contains(bar, `command = "show power.draw laptop"`) { + t.Fatalf("the block does not read this machine's key:\n%s", bar) + } + if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}); len(got) != 1 || got[0] != "power.draw" { + t.Fatalf("the holder is granted %v", got) + } + // Only the holder, and only for what the modules on its machine offer. + if got := ReadsGranted(power, []Manifest{barHolder(), power}); len(got) != 0 { + t.Fatalf("a contributor is granted %v", got) + } + if got := ReadsGranted(barHolder(), []Manifest{barHolder()}); len(got) != 0 { + t.Fatalf("a holder alone is granted %v", got) + } +} + +func TestAModuleOffersItsOwnStateOnly(t *testing.T) { + block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}` + cases := map[string]string{ + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep", + `{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep", + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err) + } + } + ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("its own state was refused: %v", err) + } +} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 8311527d..de46f7f6 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -79,6 +79,10 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { "be derived", module, n.Name) } d := declaredFor(m, seats) + // And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255). + if granted := catalogue.ReadsGranted(m, onMachine(declared, modules)); len(granted) > 0 { + d.Reads = append(append([]string(nil), d.Reads...), granted...) + } d.Holds = heldHere(d.Holds, holdings, n.Name, module) out.Assigned[n.Name] = append(out.Assigned[n.Name], d) if m.Instances == catalogue.InstancesInterchangeable { @@ -103,6 +107,17 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { return out, nil } +// onMachine is the manifests of the modules assigned to one machine that the catalogue knows. +func onMachine(declared map[string]catalogue.Manifest, modules []string) []catalogue.Manifest { + var out []catalogue.Manifest + for _, module := range modules { + if m, known := declared[module]; known { + out = append(out, m) + } + } + return out +} + // declaredFor is one module's manifest as the composer needs it: what it says about itself, and the // protocol of every seat it holds or uses. func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) broker.Declared { diff --git a/internal/inventory/busrecords_test.go b/internal/inventory/busrecords_test.go index 3cafa961..b0f2a5d0 100644 --- a/internal/inventory/busrecords_test.go +++ b/internal/inventory/busrecords_test.go @@ -296,3 +296,48 @@ func TestTheBussOwnModuleBecomesTheSnapshotUser(t *testing.T) { t.Fatalf("users derived: %v", seen) } } + +// The bar's holder reads the state the modules beside it offer in its blocks (novox/hq ADR 0255), +// without naming them: granted on the machine where both are, and nowhere else. +func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) { + bar := catalogue.Manifest{Module: "a-bar", Version: "1", + Claims: []catalogue.Claim{{Name: catalogue.BarSeat, Scope: catalogue.ScopeNode}}} + power := catalogue.Manifest{Module: "power", Version: "1", + State: []catalogue.StateDeclaration{{Name: "draw"}}, + Contributions: []catalogue.SeatContribution{{Seat: catalogue.BarSeat, Kind: catalogue.BarKindBlock, + Data: map[string]any{"bar": "bottom", "place": "status", "shows": "state", + "options": map[string]any{"state": "power.draw"}}}}} + inv, ctx := aMeshWith(t, bar, power) + for node, modules := range map[string][]string{"laptop": {"a-bar", "power"}, "desk": {"a-bar"}} { + if _, err := inv.AddNode(ctx, node); err != nil { + t.Fatal(err) + } + for _, module := range modules { + if _, err := inv.Assign(ctx, node, module); err != nil { + t.Fatal(err) + } + } + } + records, err := inv.BusRecords(ctx) + if err != nil { + t.Fatal(err) + } + reads := func(node, module string) []string { + for _, d := range records.Assigned[node] { + if d.Module == module { + return d.Reads + } + } + t.Fatalf("%s is not on %s", module, node) + return nil + } + if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != "power.draw" { + t.Fatalf("the laptop's bar reads %v", got) + } + if got := reads("desk", "a-bar"); len(got) != 0 { + t.Fatalf("a bar with no power beside it reads %v", got) + } + if got := reads("laptop", "power"); len(got) != 0 { + t.Fatalf("power reads %v", got) + } +}