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
@@ -22,8 +22,8 @@ type manifest struct {
Protocol string `json:"protocol"`
From string `json:"from"`
} `json:"listens"`
Settings map[string]any `json:"settings"`
Resources []map[string]any `json:"resources"`
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
}
func readManifest(t *testing.T) (manifest, string) {
@@ -83,28 +83,34 @@ func TestItListensOnTwoThousandFortyNineFromTheMeshOnlyAndProvidesTheShare(t *te
t.Errorf("the module opens %s", port)
}
}
if !strings.Contains(raw, "vers3=n") {
nfsConf := ""
for _, r := range m.Resources {
if r["id"] == "nfs-conf" {
nfsConf = templateText(t, r)
}
}
if !strings.Contains(nfsConf, "vers3=n") {
t.Errorf("NFS version 3 is not switched off, so 2049 alone would not do")
}
}
// The definition names no folder, no machine and no person (novox/hq ADR 0112): the shares are the
// assignment's setting, and the range the mesh's.
// assignment's config value, required and with no default (ADR 0348 §4), and the range the mesh's.
func TestTheDefinitionNamesNoFolderNoMachineAndGivesTheSharesNoDefault(t *testing.T) {
m, raw := readManifest(t)
if hit := regexp.MustCompile(`\b(g14|novox|ace|shanks|jochen)\b|/home/|/storage|/srv/`).FindString(strings.ReplaceAll(raw, "novox/hq", "")); hit != "" {
t.Errorf("the manifest says %q", hit)
}
if _, declared := m.Settings["shares"]; declared {
t.Errorf("the shares are the operator's value and have no default (ADR 0262)")
if s := m.Config["shares"]; s["required"] != true || s["default"] != nil || s["type"] != "string" {
t.Errorf("the shares are the operator's value: required, with no default (ADR 0348 §4): %v", s)
}
var config string
for _, r := range m.Resources {
if r["id"] == "config" {
config, _ = r["content"].(string)
config = templateText(t, r)
}
}
if !strings.Contains(config, "shares=${setting:shares}") || !strings.Contains(config, "range=${machine:mesh-range}") {
if !strings.Contains(config, "shares=${config:shares}") || !strings.Contains(config, "range=${machine:mesh-range}") {
t.Errorf("the config file: %q", config)
}
// Every long-running resource says how it is ready (ADR 0240 rule 8).
@@ -124,9 +130,9 @@ func TestTheDefinitionNamesNoFolderNoMachineAndGivesTheSharesNoDefault(t *testin
func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing.T) {
m, raw := readManifest(t)
var full struct {
Reads []string `json:"reads"`
Facts map[string]map[string]string `json:"facts"`
Settings map[string]map[string]any `json:"settings"`
Reads []string `json:"reads"`
Facts map[string]map[string]string `json:"facts"`
Config map[string]map[string]any `json:"config"`
}
if err := json.Unmarshal([]byte(raw), &full); err != nil {
t.Fatal(err)
@@ -138,7 +144,7 @@ func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing.
if roster["path"] != RosterFile || !strings.Contains(roster["template"], "{{range .Machines}}{{.Name}} {{.Address}}") {
t.Errorf("the roster fact: %v", roster)
}
if g := full.Settings["grants"]; g["kind"] != "preference" || g["default"] != "none" {
if g := full.Config["grants"]; g["type"] != "string" || g["default"] != "none" || g["trusted"] != true {
t.Errorf("grants: %v", g)
}
order := map[string]int{}
@@ -146,13 +152,13 @@ func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing.
for i, r := range m.Resources {
order[r["id"].(string)] = i
if r["id"] == "config" {
config, _ = r["content"].(string)
config = templateText(t, r)
}
if r["path"] == WantsDir && r["owner"] != "${machine:account}" {
t.Errorf("the tools, as the operator account, cannot write the wants: %v", r)
}
}
if !strings.Contains(config, "grants=${setting:grants}") {
if !strings.Contains(config, "grants=${config:grants}") {
t.Errorf("the config lacks the grants: %q", config)
}
if _, ok := order["wants-dir"]; !ok {
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
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"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
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)
}
}
+12
View File
@@ -0,0 +1,12 @@
# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of
# the operator's that sorts after this one overrides it, and is theirs.
#
# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more
# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open.
[nfsd]
vers2=n
vers3=n
vers4=y
vers4.0=n
vers4.1=y
vers4.2=y
+10
View File
@@ -0,0 +1,10 @@
# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.
# Replaced on every push; change the `shares` setting, never this file.
#
# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],
# which nodes may mount each and how. The module's process exports a share to a node's private address
# only when it is granted here and that node's mounts module wants it, every client mapped to the
# folder's owner, and only to addresses inside the range below.
shares=${config:shares}
grants=${config:grants}
range=${machine:mesh-range}
+21 -9
View File
@@ -51,13 +51,20 @@
"seat:mesh-controller.seats",
"seat:mesh-controller.data"
],
"settings": {
"grants": {
"kind": "preference",
"default": "none",
"why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants"
}
},
"config": {
"shares": {
"type": "string",
"description": "The folders this machine exports, as space-separated name=path; the operator's, with no default (novox/hq ADR 0263).",
"trusted": true,
"required": true
},
"grants": {
"type": "string",
"description": "Which nodes may mount which share, and how: share=node:rw|ro[,node:rw|ro], entries separated by spaces, or none to export to no node whatever a node wants (novox/hq ADR 0263).",
"trusted": true,
"default": "none"
}
},
"facts": {
"machines": {
"path": "/etc/nfs-server/machines",
@@ -88,7 +95,7 @@
"type": "file",
"path": "/etc/nfs.conf.d/50-mesh.conf",
"mode": "0644",
"content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of\n# the operator's that sorts after this one overrides it, and is theirs.\n#\n# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more\n# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open.\n[nfsd]\nvers2=n\nvers3=n\nvers4=y\nvers4.0=n\nvers4.1=y\nvers4.2=y\n"
"template": "files/50-mesh.conf"
},
{
"id": "run-dir",
@@ -154,7 +161,7 @@
"type": "file",
"path": "/etc/nfs-server/shares.conf",
"mode": "0644",
"content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how. The module's process exports a share to a node's private address\n# only when it is granted here and that node's mounts module wants it, every client mapped to the\n# folder's owner, and only to addresses inside the range below.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n"
"template": "files/shares.conf"
}
],
"build": {
@@ -169,6 +176,11 @@
"loads": [
"nfs-server"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}