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.
276 lines
12 KiB
Go
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)
|
|
}
|
|
}
|