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:
@@ -12,29 +12,39 @@ import (
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Settings map[string]setting `json:"settings"`
|
||||
Emits []string `json:"emits"`
|
||||
Contributions []contribution `json:"contributions"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Moved bool `json:"moved"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Config map[string]config `json:"config"`
|
||||
Emits []string `json:"emits"`
|
||||
Items []item `json:"items"`
|
||||
Contributions []any `json:"contributions"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
// setting is one key's default, as the definition gives it (novox/hq ADR 0262).
|
||||
type setting struct {
|
||||
Kind string `json:"kind"`
|
||||
Default any `json:"default"`
|
||||
Why string `json:"why"`
|
||||
// config is one config value as the definition declares it (novox/hq ADR 0348 §4).
|
||||
type config struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Enum []any `json:"enum"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
Pattern string `json:"pattern"`
|
||||
Trusted bool `json:"trusted"`
|
||||
Predefined string `json:"predefined"`
|
||||
Default any `json:"default"`
|
||||
Required bool `json:"required"`
|
||||
Runtime bool `json:"runtime"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
@@ -93,26 +103,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) {
|
||||
@@ -184,31 +234,49 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
type item struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) {
|
||||
|
||||
func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
m.sameAsSource(t, "configuration", "files/dunstrc")
|
||||
m.fromTemplate(t, "configuration", "files/dunstrc")
|
||||
if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/dunst/dunstrc" {
|
||||
t.Fatalf("path: %v", p)
|
||||
}
|
||||
@@ -45,9 +45,9 @@ func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) {
|
||||
|
||||
func TestTheFileIsTheDecidedOneAndCallsRofi(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
c := m.resource(t, "configuration")["content"].(string)
|
||||
for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter ${setting:font-size}",
|
||||
"width = ${setting:width}", "dmenu = rofi -dmenu -no-custom -p dunst"} {
|
||||
c := m.text(t, "configuration")
|
||||
for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter ${config:font-size}",
|
||||
"width = ${config:width}", "dmenu = rofi -dmenu -no-custom -p dunst"} {
|
||||
if !strings.Contains(c, " "+want+"\n") {
|
||||
t.Errorf("lacks %q", want)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestTheFileIsTheDecidedOneAndCallsRofi(t *testing.T) {
|
||||
// actions closes. Every other click is as it was.
|
||||
func TestATapChoosesTheNotificationsAction(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
c := m.resource(t, "configuration")["content"].(string)
|
||||
c := m.text(t, "configuration")
|
||||
for _, want := range []string{"mouse_left_click = do_action, close_current",
|
||||
"mouse_middle_click = do_action, close_current", "mouse_right_click = close_all"} {
|
||||
if !strings.Contains(c, " "+want+"\n") {
|
||||
@@ -95,31 +95,37 @@ func TestTheMenuKeyIsABindingI3Places(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The font size and the width are preferences with today's values as their defaults (novox/hq ADR
|
||||
// 0262): a machine with a denser screen sets its own, and every other machine's file is unchanged.
|
||||
func TestTheFontSizeAndWidthAreSettingsWithTodaysDefaults(t *testing.T) {
|
||||
// The font size and the width are config values with today's values as their defaults (novox/hq ADR
|
||||
// 0348 §4): numbers with their unit said and their bounds, the font size falling back to the mesh's
|
||||
// predefined font-size (ADR 0348 §2), so a machine with a denser screen sets its own and every other
|
||||
// machine's file is unchanged. Neither is trusted: the file says "trusted": false.
|
||||
func TestTheFontSizeAndWidthAreConfigValuesWithTodaysDefaults(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
want := map[string]float64{"font-size": 10, "width": 250}
|
||||
if len(m.Settings) != len(want) {
|
||||
t.Fatalf("settings: %+v", m.Settings)
|
||||
if len(m.Config) != len(want) {
|
||||
t.Fatalf("config: %+v", m.Config)
|
||||
}
|
||||
for key, value := range want {
|
||||
s, ok := m.Settings[key]
|
||||
if !ok || s.Kind != "preference" || s.Default != value || strings.TrimSpace(s.Why) == "" {
|
||||
t.Errorf("%s: %+v, want a preference defaulting to %v with why", key, s, value)
|
||||
s, ok := m.Config[key]
|
||||
if !ok || s.Type != "number" || s.Default != value || s.Required || s.Trusted ||
|
||||
strings.TrimSpace(s.Description) == "" || s.Minimum == nil || s.Maximum == nil {
|
||||
t.Errorf("%s: %+v, want an untrusted number defaulting to %v, with a description and bounds", key, s, value)
|
||||
}
|
||||
}
|
||||
// Filled with the defaults, the file is the one every machine had before it took settings.
|
||||
c := m.resource(t, "configuration")["content"].(string)
|
||||
c = strings.ReplaceAll(c, "${setting:font-size}", "10")
|
||||
c = strings.ReplaceAll(c, "${setting:width}", "250")
|
||||
if p := m.Config["font-size"].Predefined; p != "font-size" {
|
||||
t.Errorf("the font size falls back to %q, want the predefined font-size", p)
|
||||
}
|
||||
// Filled with the defaults, the file is the one every machine had before it took config values.
|
||||
c := m.text(t, "configuration")
|
||||
c = strings.ReplaceAll(c, "${config:font-size}", "10")
|
||||
c = strings.ReplaceAll(c, "${config:width}", "250")
|
||||
for _, want := range []string{"font = Inter 10", "width = 250"} {
|
||||
if !strings.Contains(c, " "+want+"\n") {
|
||||
t.Errorf("filled with the defaults, lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(c, "${setting:") {
|
||||
t.Errorf("asks for a setting it gives no default: %s", c)
|
||||
if strings.Contains(c, "${config:") || strings.Contains(c, "${setting:") {
|
||||
t.Errorf("asks for a config value it gives no default: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,3 +151,21 @@ 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)
|
||||
}
|
||||
|
||||
// dunst moves first (design 57 P6, issue 672): marked moved, its dunstrc is filled on the node by the provisioner's
|
||||
// client from its template, and reloaded as the file declares.
|
||||
func TestItIsHandedToTheProvisioner(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if !m.Moved {
|
||||
t.Fatal("dunst is not marked moved")
|
||||
}
|
||||
m.fromTemplate(t, "configuration", "files/dunstrc")
|
||||
if on := m.resource(t, "notifier")["reload-on"]; !reflect.DeepEqual(on, []any{"configuration"}) {
|
||||
t.Fatalf("the notifier is not reloaded on its dunstrc: %v", on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
follow = none
|
||||
|
||||
# Geometry
|
||||
width = ${setting:width}
|
||||
width = ${config:width}
|
||||
height = (0, 300)
|
||||
origin = top-right
|
||||
offset = (10, 50)
|
||||
@@ -39,7 +39,7 @@
|
||||
corner_radius = 0
|
||||
|
||||
# Text: the interface face (research 026/04)
|
||||
font = Inter ${setting:font-size}
|
||||
font = Inter ${config:font-size}
|
||||
line_height = 0
|
||||
markup = full
|
||||
format = "<b>%s</b>\n%b"
|
||||
|
||||
+27
-16
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"module": "dunst",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
@@ -22,18 +23,23 @@
|
||||
"dunst_count",
|
||||
"dunst_reload"
|
||||
],
|
||||
"settings": {
|
||||
"font-size": {
|
||||
"kind": "preference",
|
||||
"default": 10,
|
||||
"why": "10 points of Inter reads well on a screen of about 100 DPI; a denser screen needs a larger size"
|
||||
"config": {
|
||||
"font-size": {
|
||||
"type": "number",
|
||||
"description": "The size of a notification's text, in points of Inter: 10 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
|
||||
"minimum": 6,
|
||||
"maximum": 72,
|
||||
"predefined": "font-size",
|
||||
"default": 10
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"description": "The width of a notification, in pixels: 250 fits a title of about forty characters at 10 points, and a larger font needs a wider notification.",
|
||||
"minimum": 50,
|
||||
"maximum": 4000,
|
||||
"default": 250
|
||||
}
|
||||
},
|
||||
"width": {
|
||||
"kind": "preference",
|
||||
"default": 250,
|
||||
"why": "250 pixels fits a title of about forty characters at 10 points; a larger font needs a wider notification"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
@@ -66,7 +72,7 @@
|
||||
"path": "${machine:account-home}/.config/dunst/dunstrc",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in\n # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that\n # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the\n # desktop's default browser.\n dmenu = rofi -dmenu -no-custom -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n # A tap answers (the touchpads have no middle button). do_action runs the notification's only\n # action, or opens the context menu when it has several; without actions it opens its one link,\n # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown.\n mouse_left_click = do_action, close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n"
|
||||
"template": "files/dunstrc"
|
||||
},
|
||||
{
|
||||
"id": "notifier",
|
||||
@@ -91,6 +97,11 @@
|
||||
"loads": [
|
||||
"dunst-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -99,11 +110,11 @@
|
||||
"started",
|
||||
"paused"
|
||||
],
|
||||
"contributions": [
|
||||
"items": [
|
||||
{
|
||||
"seat": "node-display-session",
|
||||
"kind": "config",
|
||||
"content": "# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is\n# chosen from the keyboard as a tap chooses it on the notification.\nbindsym $mod+n exec --no-startup-id dunstctl context\n"
|
||||
"collection": "contribution:node-display-session:config",
|
||||
"name": "display-session",
|
||||
"template": "files/i3/50-dunst.conf"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user