A derived read reached the whole bucket, and the power module could write every machine's draw. A read granted for a block now reaches that block's key alone, by the direct get of its subject and a consumer filtered to it, and state declared per-machine is written and read at the machine's own key only.
490 lines
18 KiB
Go
490 lines
18 KiB
Go
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
|
||
// Reads is the state on the bus a piece shows, each a state and the one key of it, or nothing
|
||
// (novox/hq ADR 0260): a contributor offers only state it keeps itself, and the holder on the same
|
||
// machine is granted to read that key, so the holder's manifest names no contributor.
|
||
Reads func(data map[string]any) []StateRead `json:"-"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 _, sr := range r.Shape.Reads(c.Data) {
|
||
module, local, err := ReadState(sr.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 0260)", m.Module, i+1, sr.Read, m.Module))
|
||
}
|
||
if sr.Key != "" && !stateKeyName.MatchString(sr.Key) {
|
||
problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+
|
||
"digits and hyphens", m.Module, i+1, sr.Key))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// StateRead is one key of another module's state that a holder reads: Read is `<module>.<name>`,
|
||
// and Key the one key, empty for the holder's own machine until ReadsGranted names it.
|
||
type StateRead struct {
|
||
Read, Key string
|
||
}
|
||
|
||
// stateKeyName is a key a piece may name: a machine's name is one.
|
||
var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
// 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 0260), each with the one key the piece shows — the
|
||
// machine's own when it names none — sorted and once each. The bus grants the holder these keys and
|
||
// no others: the holder names no contributor, and reads only what is offered to it where it runs.
|
||
func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []StateRead {
|
||
seen := map[StateRead]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 _, sr := range r.Shape.Reads(c.Data) {
|
||
if sr.Key == "" {
|
||
sr.Key = machine
|
||
}
|
||
if module, _, err := ReadState(sr.Read); err == nil && module == m.Module && module != holder.Module &&
|
||
stateKeyName.MatchString(sr.Key) {
|
||
seen[sr] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out := make([]StateRead, 0, len(seen))
|
||
for sr := range seen {
|
||
out = append(out, sr)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Read != out[j].Read {
|
||
return out[i].Read < out[j].Read
|
||
}
|
||
return out[i].Key < out[j].Key
|
||
})
|
||
return out
|
||
}
|
||
|
||
// 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,
|
||
// `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 `<no value>` into the tool's file, which is refused here instead.
|
||
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
|
||
}
|
||
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, 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() {
|
||
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, machine, 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", "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))
|
||
}
|
||
}
|
||
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, ",")
|
||
}
|