Files
mesh-catalog/modules/zfs/cmd/zfs-tools/manifest_test.go
T
jochen a9acd3dd28
mesh/merge-gate pass: builds zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Keep the scheduled run's served slot in a folder of root's alone, written never through a link
Third review of #147. The run writes as root into /var/lib/zfs-scrub (0700, root's), apart from the
operator account's error counts in /var/lib/zfs-tools, and refuses a folder that is a link, open to
others or someone else's, and a file that is a link: a new file opened O_NOFOLLOW|O_EXCL, renamed over.
2026-10-09 01:55:54 +02:00

276 lines
12 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"time"
)
type manifest struct {
Tools []string `json:"tools"`
Claims []any `json:"claims"`
Emits []string `json:"emits"`
Replaces map[string][]string `json:"replaces"`
Resources []map[string]any `json:"resources"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m, string(raw)
}
// The manifest lists exactly the tools the bundle serves; every one but zfs_snapshot says it only reads,
// and zfs_snapshot says it acts. Each says what it replaces (novox/hq ADR 0245), but the health check,
// which replaces nothing. No seat: a new mesh or node seat is the controller's to define (ADR 0121).
func TestTheManifestNamesTheToolsTheBundleServesAndWhatEachReplaces(t *testing.T) {
m, raw := readManifest(t)
var served []string
for _, tl := range Tools(newFake(t).machine()) {
served = append(served, tl.Name)
want := "(r)"
if tl.Name == "zfs_snapshot" || strings.HasPrefix(tl.Name, "zfs_scrub_") {
want = "(a)"
}
if !strings.HasSuffix(tl.Description, want) {
t.Errorf("%s does not end with %s", tl.Name, want)
}
if tl.Name != "zfs_health" && len(m.Replaces[tl.Name]) == 0 {
t.Errorf("%s says nothing of what it replaces", tl.Name)
}
for _, c := range m.Replaces[tl.Name] {
if !strings.Contains(tl.Description, c) {
t.Errorf("%s replaces %q in the manifest and not in its description", tl.Name, c)
}
}
}
sort.Strings(served)
listed := append([]string(nil), m.Tools...)
sort.Strings(listed)
if strings.Join(served, ",") != strings.Join(listed, ",") {
t.Fatalf("served %v, listed %v", served, listed)
}
if _, ok := m.Replaces["zfs_health"]; ok {
t.Errorf("the health check replaces no command")
}
if len(m.Claims) != 0 {
t.Errorf("the module claims no seat: %v", m.Claims)
}
if strings.Join(m.Emits, ",") != SnapshotEvent+","+ScrubEvent {
t.Errorf("emits %v", m.Emits)
}
if hit := regexp.MustCompile(`\b(g14|novox|ace|shanks|jochen)\b|/home/`).FindString(strings.ReplaceAll(raw, "novox/hq", "")); hit != "" {
t.Errorf("the manifest says %q", hit)
}
}
// ADR 0240: zfs_health on the watch, which runs as the operator account; the follower judged by its
// unit. The scheduled run is not long-running and declares none. The module installs nothing, so it never
// puts ZFS on a machine.
func TestTheHealthChecksAndWhoRunsWhat(t *testing.T) {
m, raw := readManifest(t)
byID := map[string]map[string]any{}
for _, r := range m.Resources {
byID[r["id"].(string)] = r
}
w := byID["watch"]
if h, _ := w["health"].(map[string]any); h["kind"] != "tool" || h["tool"] != "zfs_health" || w["user"] != "${machine:account}" {
t.Errorf("the watch runs as the operator account and carries zfs_health: %v", w)
}
e := byID["events"]
if h, _ := e["health"].(map[string]any); h["kind"] != "unit" || e["user"] != nil {
t.Errorf("the follower runs as root, judged by its unit: %v", e)
}
if d := byID["state-dir"]; d["path"] != "/var/lib/zfs-tools" || d["owner"] != "${machine:account}" ||
filepath.Dir(ErrorsFile) != d["path"] {
t.Errorf("the state the account keeps: %v", d)
}
// The scheduled run's state is root's alone, apart from the account's (third review).
if d := byID["scrub-state-dir"]; d["path"] != filepath.Dir(ServedFile) || d["mode"] != "0700" || d["owner"] != nil {
t.Errorf("the state the run keeps: %v", d)
}
if strings.Contains(raw, `"package"`) {
t.Errorf("the module installs no package: ZFS is the machine's, and its kernel module is built against its kernel")
}
}
// One schedule per pool, and no setting in any unit's name but the distribution's timers', which the
// controller fills (mesh-controller #171) and checks. The module's own schedule is the node-engine's timer
// for the scheduled run, a fixed name; the day and time reach only the configuration, which the run checks.
// The run comes before the distribution's timers are stopped, so a run that cannot be installed stops the
// apply before the timer that scrubs the pool today is touched.
func TestExactlyOneScrubScheduleAndNoSettingInAUnitFile(t *testing.T) {
m, raw := readManifest(t)
byID := map[string]map[string]any{}
order := map[string]int{}
for i, r := range m.Resources {
byID[r["id"].(string)] = r
order[r["id"].(string)] = i
}
run := byID["scrub-run"]
args, _ := run["run"].([]any)
if run["type"] != "process" || run["name"] != "zfs-mesh-scrub" || run["schedule"] != "*/15 * * * *" || run["user"] != nil ||
len(args) != 2 || args[1] != "scrub-run" {
t.Errorf("the scheduled run: %v", run)
}
if ScrubTimer != run["name"].(string)+".timer" || ScrubService != run["name"].(string)+".service" {
t.Errorf("the tools read %s and %s; the node-engine names them after %v", ScrubTimer, ScrubService, run["name"])
}
for _, c := range []string{"weekly", "monthly"} {
d := byID["distribution-"+c]
if d["unit"] != "zfs-scrub-"+c+"@${setting:scrub-pool}.timer" || d["state"] != "stopped" || d["boot"] != "disabled" {
t.Errorf("the distribution's %s timer: %v", c, d)
}
if order["scrub-run"] > order["distribution-"+c] {
t.Errorf("the distribution's %s timer is stopped before the run is installed", c)
}
}
for _, r := range m.Resources {
p, _ := r["path"].(string)
if strings.HasPrefix(p, "/etc/systemd/") {
t.Errorf("the module writes a unit file of its own: %s", p)
}
}
if strings.Contains(raw, "scrub-day}") && !strings.Contains(byID["scrub-config"]["content"].(string), "${setting:scrub-day}") {
t.Errorf("the day reaches something other than the configuration")
}
if n := strings.Count(raw, "${setting:scrub-day}") + strings.Count(raw, "${setting:scrub-time}") + strings.Count(raw, "${setting:scrub-cadence}"); n != 3 {
t.Errorf("the day, time and cadence are written %d times; only into the configuration", n)
}
var settings struct {
Settings map[string]map[string]any `json:"settings"`
}
json.Unmarshal([]byte(raw), &settings)
if settings.Settings["scrub-cadence"]["default"] != "weekly" || settings.Settings["scrub-day"]["default"] != "Mon" ||
settings.Settings["scrub-time"]["default"] != "00:00" {
t.Errorf("defaults: %v", settings.Settings)
}
if _, has := settings.Settings["scrub-pool"]; has {
t.Errorf("the pool is the operator's value, with no default (ADR 0112, ADR 0262)")
}
}
// The home server, composed as the controller fills its settings (pool storage, cadence monthly, the
// operator's choice): the units the node-engine is sent and the configuration the run reads.
func TestTheHomeServersComposedUnitsAndConfiguration(t *testing.T) {
m, _ := readManifest(t)
fill := strings.NewReplacer("${setting:scrub-pool}", "storage", "${setting:scrub-cadence}", "monthly",
"${setting:scrub-day}", "Mon", "${setting:scrub-time}", "00:00")
var units []string
var conf string
for _, r := range m.Resources {
switch {
case r["type"] == "service":
units = append(units, fill.Replace(r["unit"].(string))+" "+r["state"].(string)+"/"+r["boot"].(string))
case r["type"] == "process" && r["schedule"] != nil:
units = append(units, r["name"].(string)+".timer running/enabled (the node-engine's, every quarter of an hour)")
case r["id"] == "scrub-config":
conf = fill.Replace(r["content"].(string))
}
}
want := []string{"zfs-mesh-scrub.timer running/enabled (the node-engine's, every quarter of an hour)",
"zfs-scrub-weekly@storage.timer stopped/disabled", "zfs-scrub-monthly@storage.timer stopped/disabled"}
if strings.Join(units, "\n") != strings.Join(want, "\n") {
t.Errorf("units:\n%s", strings.Join(units, "\n"))
}
for _, u := range units {
t.Log(u)
}
s, err := ParseScrubConfig(conf)
if err != nil || s.Pool != "storage" || s.Cadence != "monthly" {
t.Fatalf("configuration %q: %v", conf, err)
}
t.Logf("next scrub of storage after 2026-10-09 12:00: %s", s.NextSlot(time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local)).Format("2006-01-02 15:04"))
if next := s.NextSlot(time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local)); !next.Equal(time.Date(2026, 11, 2, 0, 0, 0, 0, time.Local)) {
t.Errorf("next monthly slot %s, want the first Monday of November", next)
}
}
// Nothing but reading, a snapshot and a scrub's start, pause or stop. Every command the bundle runs goes
// through ask, AsRoot, Escalated, Run, exec.Command or exec.CommandContext with an argument list from one
// of the functions below, each held to its program and verb; a call site that builds its own list fails
// this test. A new verb needs a decision first, and then a line here.
func TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub(t *testing.T) {
allowed := map[string][2]string{
"DatasetsArgv": {"zfs", "list"}, "SnapshotsArgv": {"zfs", "list"}, "PropertiesArgv": {"zfs", "get"},
"SnapshotArgv": {"zfs", "snapshot"},
"PoolsArgv": {"zpool", "list"}, "StatusArgv": {"zpool", "status"}, "IOArgv": {"zpool", "iostat"},
"EventsArgv": {"zpool", "events"}, "HistoryArgv": {"zpool", "history"}, "FollowArgv": {"zpool", "events"},
"ScrubArgv": {"zpool", "scrub"}, "WaitArgv": {"zpool", "wait"},
"TimerShowArgv": {"systemctl", "show"}, "ServiceShowArgv": {"systemctl", "show"},
}
verbs := map[string][]string{"DatasetsArgv": DatasetsArgv(), "SnapshotsArgv": SnapshotsArgv(), "PoolsArgv": PoolsArgv(),
"StatusArgv": StatusArgv(), "IOArgv": IOArgv(3), "EventsArgv": EventsArgv(), "HistoryArgv": HistoryArgv("tank"),
"PropertiesArgv": PropertiesArgv("tank"), "SnapshotArgv": SnapshotArgv("tank", "x"), "FollowArgv": FollowArgv(),
"TimerShowArgv": TimerShowArgv("x.timer"), "ServiceShowArgv": ServiceShowArgv("x.service"), "WaitArgv": WaitArgv("tank")}
for fn, argv := range verbs {
if argv[0] != allowed[fn][1] {
t.Errorf("%s runs %q, not %q", fn, argv[0], allowed[fn][1])
}
}
for _, act := range []string{"start", "pause", "stop", "anything"} {
argv := ScrubArgv(act, "tank")
if argv[0] != "scrub" || argv[len(argv)-2] != "--" || argv[len(argv)-1] != "tank" || len(argv) > 4 {
t.Errorf("%s: %v", act, argv)
}
if len(argv) == 4 && argv[1] != "-p" && argv[1] != "-s" {
t.Errorf("%s: %v", act, argv)
}
}
call := regexp.MustCompile(`(\.(ask|AsRoot|Escalated|Run|Start|Output|CombinedOutput)\(|exec\.Command(Context)?\(|os\.StartProcess\(|syscall\.(Exec|ForkExec)\()`)
site := regexp.MustCompile(`(?:\.(?:ask|AsRoot|Escalated)|exec\.Command)\((?:\w+, )?"(zfs|zpool|systemctl)", (\w+)\(`)
// The bodies that run what they are given, each once.
bodies := map[string]bool{
"machine.go: r := m.Run(ctx, name, args...)": true,
"machine.go: r := m.Run(ctx, program, argv...)": true,
"machine.go: program, argv := m.Escalated(name, args...)": true,
"machine.go: cmd := exec.Command(name, args...)": true,
"machine.go: if err := cmd.Start(); err != nil {": true,
"follow.go: if err := cmd.Start(); err != nil {": true,
}
files, _ := filepath.Glob("*.go")
sites := 0
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for i, l := range strings.Split(string(b), "\n") {
trimmed := strings.TrimSpace(l)
if !call.MatchString(l) || strings.HasPrefix(trimmed, "//") || bodies[file+":"+l] {
continue
}
m := site.FindStringSubmatch(l)
if m == nil {
t.Errorf("%s:%d runs a command not built by an argument function: %s", file, i+1, trimmed)
continue
}
want, ok := allowed[m[2]]
if !ok {
t.Errorf("%s:%d runs %s's list, which is not held here", file, i+1, m[2])
} else if want[0] != m[1] {
t.Errorf("%s:%d runs %s with %s's list", file, i+1, m[1], m[2])
}
sites++
}
}
if sites < 14 {
t.Errorf("found %d call sites; the test no longer sees them", sites)
}
}