Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+13 -12
View File
@@ -3,7 +3,7 @@
The bars as a module (novox/hq ADR 0208, research 026/05).
- Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition.
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and renders the blocks other modules
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and places the blocks other modules
contribute to it (ADR 0255, below). Requires `x11-display` on its own
machine: its bars are i3bar's.
- Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set
@@ -29,18 +29,19 @@ The bars as a module (novox/hq ADR 0208, research 026/05).
## Blocks other modules contribute (novox/hq ADR 0255)
A block that depends on the machine's hardware is not written here. The module that knows the hardware
contributes it to `node-bar` as data that says what it shows, and this module renders it:
contributes it as text, in this module's grammar, to the bar's collection of blocks:
- **What a contribution says:** which bar (`bottom`, `top`), which place (`resources`, beside the
processor, memory and disks; `status`, beside the sound), an order from 0 to 99, what it shows
(`battery`, `command`) and that kind's options. Nothing in it is i3status-rust's.
- **How it is rendered:** the claim's `renders.block` is a template (its source is `files/block.tmpl`)
that turns one piece into a `[[block]]`. The controller runs it and places the result where each bar
says `${contribution:node-bar:block:bar=…,place=…}`. Each bar places both of its places once; the
catalogue check refuses a holder that misses one.
- **Where it applies:** a contribution may name a capability the machine must report. The `power`
module's battery block names `battery` and its power-draw block names `power-meter`, so the laptop's
bar gets both and a desktop only the power draw.
- **What a contributor gives:** an item of the collection `contribution:node-bar:block`, the block text in
i3status-rust's grammar (a `[[block]]`), with the attributes `bar` (`bottom`, `top`), `place` (`resources`,
beside the processor, memory and disks; `status`, beside the sound) and `order` (0 to 99), and, where the
block depends on the hardware, the capability the machine must report.
- **How it is placed:** each bar says `${collected:contribution:node-bar:block:bar=…,place=…}`, where the node's
provisioner client fills the items that carry those attributes, in order, each under a line naming its
contributor. Each bar places both of its places once. Where the client does not fill the file, the controller
fills the same text the same way.
- **Where it applies:** an item may name a capability the machine must report. The `power` module's battery item
names `battery` and its power-draw item names `power-meter`, so the laptop's bar gets both and a desktop only
the power draw.
- **A value from the bus:** a block that `shows: state` names another module's state, such as
`power.draw`. The mesh grants this module the read, because a module on its machine offers it; the
manifest names no other module. This module's bundle watches it and leaves the value's text in the
@@ -1,87 +1,27 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"text/template"
)
// i3status-rust renders the blocks other modules contribute (novox/hq ADR 0255): a bar-neutral piece
// — what it shows, on which bar, where — in its own grammar, through the template its claim gives. The
// controller runs the template; these tests run it the same way, with the one function it is given.
func blockTemplate(t *testing.T) *template.Template {
t.Helper()
m := readManifest(t)
src, err := os.ReadFile("../../files/block.tmpl")
if err != nil {
t.Fatal(err)
}
if m.Claims[0].Renders["block"] != string(src) {
t.Fatal("the claim's template is not files/block.tmpl")
}
quote := func(v any) (string, error) {
b, err := json.Marshal(fmt.Sprint(v))
return string(b), err
}
return template.Must(template.New("block").Funcs(template.FuncMap{"quote": quote}).Parse(string(src)))
}
func render(t *testing.T, tmpl *template.Template, piece string) string {
t.Helper()
view := map[string]any{"module": "power", "machine": "laptop"}
if err := json.Unmarshal([]byte(piece), &view); err != nil {
t.Fatal(err)
}
var b bytes.Buffer
if err := tmpl.Execute(&b, view); err != nil {
t.Fatal(err)
}
if strings.Contains(b.String(), "<no value>") {
t.Fatalf("%s rendered a missing value: %q", piece, b.String())
}
return b.String()
}
func TestAContributedBatteryIsI3statusRustsBatteryBlock(t *testing.T) {
tmpl := blockTemplate(t)
got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery"}`)
if !strings.HasPrefix(got, "[[block]]\nblock = \"battery\"\nformat = ") || strings.Contains(got, "device") ||
!strings.Contains(got, `missing_format = ""`) || !strings.HasSuffix(got, "\n\n") {
t.Fatalf("%q", got)
}
got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery","options":{"device":"BAT1"}}`)
if !strings.Contains(got, "block = \"battery\"\ndevice = \"BAT1\"\nformat = ") {
t.Fatalf("%q", got)
}
}
func TestAContributedCommandIsACustomBlockQuoted(t *testing.T) {
got := render(t, blockTemplate(t),
`{"bar":"bottom","place":"status","shows":"command","options":{"command":"say \"hi\"","interval":5}}`)
want := "[[block]]\nblock = \"custom\"\ncommand = \"say \\\"hi\\\"\"\ninterval = 5\n\n"
if got != want {
t.Fatalf("%q, want %q", got, want)
}
}
// The blocks other modules contribute are a collection of text (design 57 L3, novox/hq ADR 0349 clause 13): each
// contributor gives its block in i3status-rust's grammar, and the bars place the collection where they want it.
func TestEachBarPlacesBothPlacesOnce(t *testing.T) {
m := readManifest(t)
for _, bar := range []string{"bottom", "top"} {
content := m.resource(t, bar+"-bar")["content"].(string)
content := m.text(t, bar+"-bar")
for _, place := range []string{"resources", "status"} {
ph := "${contribution:node-bar:block:bar=" + bar + ",place=" + place + "}"
ph := "${collected:contribution:node-bar:block:bar=" + bar + ",place=" + place + "}"
if strings.Count(content, ph) != 1 {
t.Errorf("the %s bar places %s %d times", bar, ph, strings.Count(content, ph))
}
}
}
bottom := m.resource(t, "bottom-bar")["content"].(string)
bottom := m.text(t, "bottom-bar")
resources := strings.Index(bottom, "place=resources")
if !(strings.Index(bottom, `block = "memory"`) < resources && resources < strings.Index(bottom, `block = "docker"`)) ||
!(strings.Index(bottom, `block = "sound"`) < strings.Index(bottom, "place=status") &&
@@ -91,30 +31,33 @@ func TestEachBarPlacesBothPlacesOnce(t *testing.T) {
}
func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) {
bottom := readManifest(t).resource(t, "bottom-bar")["content"].(string)
bottom := readManifest(t).text(t, "bottom-bar")
if !strings.Contains(bottom, `format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"`) ||
strings.Contains(bottom, "mem_total_used") {
t.Fatal("the memory block counts the cache as used")
}
}
func TestAContributedStateIsACustomBlockReadingThisMachinesValue(t *testing.T) {
tmpl := blockTemplate(t)
got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`)
want := "[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-value power.draw laptop\"\ninterval = 5\nhide_when_empty = true\n\n"
if got != want {
t.Fatalf("%q, want %q", got, want)
func TestABarNamesNoTemplateOfItsOwnForTheBlocksItPlaces(t *testing.T) {
m := readManifest(t)
for _, id := range []string{"top-bar", "bottom-bar"} {
if strings.Contains(m.text(t, id), "${contribution:") {
t.Errorf("the %s still places a contribution the old way", id)
}
}
got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw","key":"desk"}}`)
if !strings.Contains(got, "i3status-value power.draw desk") {
t.Fatalf("%q", got)
if _, err := os.Stat("../../files/block.tmpl"); err == nil {
t.Error("files/block.tmpl is still there, and no claim renders it")
}
// And the bundle finds what it rendered.
}
func TestTheBundleFindsTheValueARenderedBlockShows(t *testing.T) {
// What the power module's block renders on a machine called laptop.
got := "[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-value power.draw laptop\"\ninterval = 5\nhide_when_empty = true\n\n"
f := filepath.Join(t.TempDir(), "bottom-bar.toml")
if err := os.WriteFile(f, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
if w := wantedIn([]string{f}); len(w) != 1 || w[0] != (Wanted{"power.draw", "desk"}) {
if w := wantedIn([]string{f}); len(w) != 1 || w[0] != (Wanted{"power.draw", "laptop"}) {
t.Fatalf("%v", w)
}
}
@@ -14,6 +14,8 @@ import (
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Moved bool `json:"moved"`
Items []item `json:"items"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
@@ -23,7 +25,7 @@ type manifest struct {
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
Contributions []contribution `json:"contributions"`
Contributions []any `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
@@ -48,6 +50,53 @@ type shellCode struct {
Code string `json:"code"`
}
// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest.
type item struct {
Collection string `json:"collection"`
Name string `json:"name"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
}
// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of
// the collections shell:<file>:<slot>, in the order they are declared.
func (m manifest) shell(t *testing.T) []shellCode {
t.Helper()
var out []shellCode
for _, it := range m.Items {
parts := strings.Split(it.Collection, ":")
if len(parts) != 3 || parts[0] != "shell" {
continue
}
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)})
}
return out
}
// environmentOf is the variables the module sets, read from the templates of its items of the environment.
func (m manifest) environmentOf(t *testing.T) *environment {
t.Helper()
var env *environment
for _, it := range m.Items {
if it.Collection != "environment" || it.Attributes["kind"] != "variable" {
continue
}
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
if env == nil {
env = &environment{Variables: map[string]string{}}
}
env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
}
return env
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
@@ -60,6 +109,7 @@ func readManifest(t *testing.T) manifest {
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
m.Environment = m.environmentOf(t)
return m
}
@@ -87,26 +137,66 @@ func (m manifest) packages() (present, absent []string) {
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
// fromTemplate checks that a file resource takes its text from its template file in the module's
// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the
// template is there. The readable file in the repository is the one place the machine's text is written.
func (m manifest) fromTemplate(t *testing.T, id, template string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
if r["template"] != template {
t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"])
}
if _, inline := r["content"]; inline {
t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id)
}
if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil {
t.Fatalf("resource %s names %s: %v", id, template, err)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs
// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func (m manifest) everyFileIsATemplate(t *testing.T) {
t.Helper()
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
// text is the text a file resource places on the machine: its template file's.
func (m manifest) text(t *testing.T, id string) string {
t.Helper()
name, _ := m.resource(t, id)["template"].(string)
if name == "" {
t.Fatalf("resource %s names no template", id)
}
raw, err := os.ReadFile(filepath.Join("..", "..", name))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
@@ -178,31 +268,42 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
}
}
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// displaySession is the collection the module's lines for the window manager are an item of.
const displaySession = "contribution:node-display-session:config"
// i3Lines is what the module contributes to the window manager.
// i3Lines is what the module contributes to the window manager: the text of its item of the collection
// contribution:node-display-session:config (design 57 L3, issue 848).
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
for _, it := range m.Items {
if it.Collection == displaySession {
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
out += string(text)
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no
// old-form contribution to node-display-session is left.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
if len(m.Contributions) != 0 {
t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
var templates []string
for _, it := range m.Items {
if it.Collection == displaySession {
templates = append(templates, it.Template)
}
}
if len(templates) != 1 || templates[0] != source {
t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source)
}
if m.i3Lines() == "" {
t.Fatalf("the template %s holds no line", source)
}
}
@@ -13,11 +13,14 @@ import (
func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) {
m := readManifest(t)
if !m.Moved {
t.Fatal("the module is not marked moved")
}
if m.Module != "i3status-rust" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if len(m.Claims) != 1 || m.Claims[0].Name != "node-bar" || m.Claims[0].Scope != "node" ||
len(m.Claims[0].Renders) != 1 || m.Claims[0].Renders["block"] == "" {
len(m.Claims[0].Renders) != 0 {
t.Fatalf("claims: %+v", m.Claims)
}
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
@@ -35,13 +38,13 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog",
"value": "files/bin/i3status-value",
} {
m.sameAsSource(t, id, src)
m.fromTemplate(t, id, src)
}
}
func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testing.T) {
m := readManifest(t)
bottom := m.resource(t, "bottom-bar")["content"].(string)
bottom := m.text(t, "bottom-bar")
for _, never := range []string{`block = "battery"`, `block = "amd_gpu"`, `block = "nvidia_gpu"`, "mac = ", "api_key", "city_id", "openweathermap", "~/scripts/"} {
if strings.Contains(bottom, never) {
t.Errorf("the bottom bar has %s", never)
@@ -57,9 +60,9 @@ func TestTheBarsAreAContributionToTheWindowManagerAndTheWatchdogRunsOncePerSessi
!strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") {
t.Fatalf("%s", c)
}
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" ||
!strings.Contains(m.Shell[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) {
t.Fatalf("%+v", m.Shell)
if len(m.shell(t)) != 1 || m.shell(t)[0].For != "xinitrc" || m.shell(t)[0].Slot != "normal" ||
!strings.Contains(m.shell(t)[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) {
t.Fatalf("%+v", m.shell(t))
}
}
@@ -68,3 +71,8 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
// No file resource carries its text inline (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
readManifest(t).everyFileIsATemplate(t)
}
-26
View File
@@ -1,26 +0,0 @@
{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller
renders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}
{{- if eq .shows "battery" -}}
[[block]]
block = "battery"
{{with .options}}{{with .device}}device = {{quote .}}
{{end}}{{end -}}
format = "$icon $percentage{ $time|}"
charging_format = "$icon $percentage{ $time|}"
not_charging_format = "$icon $percentage"
full_format = "$icon $percentage"
empty_format = "$icon $percentage"
missing_format = ""
{{- else if eq .shows "command" -}}
[[block]]
block = "custom"
command = {{quote .options.command}}
interval = {{.options.interval}}
{{- else if eq .shows "state" -}}
[[block]]
block = "custom"
command = {{quote (printf "~/.local/bin/i3status-value %s %s" .options.state (or .options.key .machine))}}
interval = 5
hide_when_empty = true
{{- end}}
+2 -2
View File
@@ -74,7 +74,7 @@ format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
# what they show by this module's template, and only on a machine that has what they show.
${contribution:node-bar:block:bar=bottom,place=resources}
${collected:contribution:node-bar:block:bar=bottom,place=resources}
[[block]]
block = "docker"
interval = 2
@@ -88,7 +88,7 @@ cmd = "pavucontrol"
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
# power draw, where the machine has them.
${contribution:node-bar:block:bar=bottom,place=status}
${collected:contribution:node-bar:block:bar=bottom,place=status}
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
[[block]]
block = "weather"
+1 -1
View File
@@ -34,4 +34,4 @@ interval = 60
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
# so that one that does is never dropped.
${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}
${collected:contribution:node-bar:block:bar=top,place=resources}${collected:contribution:node-bar:block:bar=top,place=status}
@@ -0,0 +1,3 @@
# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this
# brings it back. Once per session, ending with the session's own process ($$).
"$HOME/.local/bin/i3bar-watchdog" "$$" &
File diff suppressed because one or more lines are too long