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
@@ -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)
}
}
+87
View File
@@ -0,0 +1,87 @@
# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than
# patched, because this module owns the file and a patch would leave whatever
# was there before to be discovered later.
#
# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the
# `mesh-dns-resolver` seat, which more than one machine may hold, each answering
# the same names from the same roster. Every node and every container lists every
# holder and no public resolver, so whichever answers first gives the one answer.
# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the
# mesh's, rendered into each holder; no other copy lives on any machine.
# What the mesh computed: one wildcard per machine — its name and everything
# under it — and the mesh's own suffix as a local domain, so a name under it is
# answered here or not at all and is never asked upstream. Rewritten whenever a
# machine joins or leaves, which is why the service below restarts on it: a
# reload makes dnsmasq re-read hosts files, not its configuration, and a
# wildcard is configuration.
conf-file=/etc/mesh-resolver/nodes.conf
# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to
# the private address of the machine its module runs on and the port its
# answerer is published on. Nothing in a zone is answered here; what names a zone
# holds is the module's. Rewritten whenever a zone is declared or moves, and the
# service restarts on it for the same reason as above.
conf-file=/etc/mesh-resolver/zones.conf
# Where it answers: this machine's private address, so every node — and every
# container on every node, which copies its machine's resolvers — can ask; and
# loopback, for this machine's own use. Never a LAN address: a device that is
# not a member cannot reach what the mesh's names point at, and a LAN's resolver
# is its router's (ADR 0194).
#
# Named as an ADDRESS and not as the interface that carries it, on purpose. A
# container's query is addressed to this address but arrives on the runtime's
# bridge, and dnsmasq checks every query against what it was told to answer on:
# an `interface=` line admits queries by the interface they arrive on, a
# `listen-address=` line by the address they are sent to. With `interface=mesh0`
# alone, a query from a container was received and dropped without a word
# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on.
#
# bind-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic
# re-reads the machine's addresses on every change of any link and closes each
# listener whose interface that reading did not find; a reading made while the
# container runtime deletes a bridge's virtual links fails ("index gone"), and
# then every listener is closed until a later change reads cleanly. On
# 2026-10-09 the private address was left unbound for twenty minutes, the daemon
# running and silent. bind-interfaces binds each address once, at start, and
# never closes it. It cannot bind an address that is not there yet, so the unit
# waits for it (the drop-in below); a new private address restarts the unit, as
# this file changing always did.
bind-interfaces
listen-address=${machine:address}
listen-address=127.0.0.1
# **It must never read resolv.conf to find out where to forward.** This
# machine's resolv.conf names this resolver — so a resolver that read it for
# upstreams would find itself, and every query it could not answer locally would
# loop until its receive queue filled. That is not theoretical: it filled with
# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop
# impossible: the upstreams are the two lines below, and nothing on the machine
# can redirect them. The mesh's own names never reach them: the local= line in
# the file above stops them here, answered or refused.
no-resolv
server=1.1.1.1
server=8.8.8.8
# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,
# and this passes that bit down rather than dropping it, which dnsmasq does
# unless told. It does not validate itself: an answer's trust is the upstream's
# and the path to it, which is what a forwarding resolver's trust always was.
# Without it a program that refuses to run behind a resolver that does not
# validate — a mail system's admin does exactly that check at start — cannot
# use this resolver (novox/hq 04-ISSUES/171).
proxy-dnssec
# A name without a dot is never forwarded, and reverse lookups of private ranges
# are answered here rather than asking the world who 10.x is.
domain-needed
bogus-priv
# **No hosts file, and no operator's files.** This machine's /etc/hosts is its
# own — the hosts module's block and the operator's lines (ADR 0199) — and the
# mesh's resolver answers every node, so a line written for one machine's own
# programs must not become an answer for all of them. Every per-node resolver
# went wrong exactly here: a hosts file read once at start, an operator's old
# line beside the mesh's, a drop-in read from a directory nobody owned.
no-hosts
+14
View File
@@ -0,0 +1,14 @@
# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push.
#
# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this
# machine's private address is there: it waits up to 80 seconds for it, and a start that found it
# missing is tried again every ten seconds rather than left failed.
[Unit]
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1'
Restart=on-failure
RestartSec=10s
File diff suppressed because one or more lines are too long