Files
mesh-host/internal/apply/plan_test.go
jochen 23a4436499 review: a unit whose file the mesh wrote is stopped when undeclared, and what was found survives a failed first apply (hq ADR 0118)
Records written before Found existed left the adoption guard and the converge filter loaded on
undeclare, then deleted their unit files from under them; a unit whose own file the mesh created
is now the mesh's, whatever its record says. Found is kept apart the moment it is read, so a
first apply that enabled and then failed is not read back as the machine's; boot is found the
first time the mesh sets it; a service once stateless, or moved to another unit, is found afresh
(the old unit given back). The unit is read after the reload that loads a file written in the
same apply, and removal reports what it actually did.
2026-09-27 00:41:02 +02:00

303 lines
14 KiB
Go

package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 104: what an apply would change is said before anything is, from the
// declaration and the node's record — and an action is named as the action it is.
func trusted(t *testing.T, raw string) *declaration.Declaration {
t.Helper()
d, err := declaration.ParseFileTrusted([]byte(raw))
if err != nil {
t.Fatalf("fixture is not a valid declaration: %v", err)
}
return d
}
func verbs(steps []Step) string {
var out []string
for _, s := range steps {
out = append(out, s.Verb+" "+s.ID)
}
return strings.Join(out, ", ")
}
func TestAPlanNamesAnActionAsAnAction(t *testing.T) {
d := trusted(t, `{"declaration":1,"resources":[
{"id":"init","type":"action","command":["createdb","mesh"],"verify":["psql","-c","select 1"]}]}`)
steps := Plan(d, store.State{}, store.OriginCarried)
if len(steps) != 1 || steps[0].Verb != "run" {
t.Fatalf("an action was planned as %s", verbs(steps))
}
if !strings.Contains(steps[0].Why, "createdb mesh") || !strings.Contains(steps[0].Why, "nothing after it") {
t.Errorf("the plan does not say what the action runs and what failing it means: %q", steps[0].Why)
}
}
func TestAPlanSaysWhatIsRecordedAndWhatIsNot(t *testing.T) {
d := parse(t, `{"declaration":1,"resources":[
{"id":"new","type":"file","path":"/tmp/new","content":"a\n"},
{"id":"same","type":"file","path":"/tmp/same","content":"b\n"},
{"id":"moved","type":"file","path":"/tmp/moved","content":"c\n"},
{"id":"sealed","type":"file","path":"/tmp/sealed","sealed":"AAAA","mode":"0600"}]}`)
known := store.State{}
known.Record(store.Applied{ID: "same", Type: "file", Target: "/tmp/same", Wrote: digestOf("b\n")})
known.Record(store.Applied{ID: "moved", Type: "file", Target: "/tmp/moved", Wrote: digestOf("old\n")})
known.Record(store.Applied{ID: "sealed", Type: "file", Target: "/tmp/sealed", Wrote: digestOf("secret")})
known.Record(store.Applied{ID: "gone", Type: "file", Target: "/tmp/gone"})
got := verbs(Plan(d, known, store.OriginCarried))
want := "remove gone, create new, check same, update moved, check sealed"
if got != want {
t.Errorf("planned %q, want %q", got, want)
}
}
func TestAPlanSaysTheFirewallAConvergingNodeRetires(t *testing.T) {
d := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, FoundAt: time.Now()}}
known.Record(store.Applied{ID: "adoption.guard.table", Type: "file", Target: "/etc/guard", Origin: store.OriginDeclared})
got := verbs(Plan(d, known, store.OriginDeclared))
// The firewall goes last but for what protected the node, which goes after it.
if got != "create a, disable ufw, remove adoption.guard.table" {
t.Errorf("a converging node planned %q", got)
}
// A file or the bundle never retires the firewall found here, and says nothing about it.
if got := verbs(Plan(d, known, store.OriginCarried)); strings.Contains(got, "ufw") {
t.Errorf("a carried declaration planned to touch the firewall: %q", got)
}
}
func TestAPlanHoldsWhatAnAdoptedNodeFound(t *testing.T) {
d := parse(t, `{"declaration":1,"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
"resources":[
{"id":"hello-web.page","type":"file","path":"/srv/index.html","content":"x\n"},
{"id":"hello-web.server","type":"container","name":"hello-web","image":"example/web@sha256:0000000000000000000000000000000000000000000000000000000000000000"}]}`)
known := store.State{}
known.RecordHeld(store.Held{ID: "hello-web.page", Module: "hello-web", Kind: "file", Target: "/srv/index.html"})
steps := Plan(d, known, store.OriginDeclared)
if len(steps) != 2 || steps[0].Verb != "hold" || steps[1].Verb != "create" {
t.Fatalf("an adopted node planned %s", verbs(steps))
}
if !strings.Contains(steps[1].Why, "held as it is until hello-web is taken") {
t.Errorf("the plan does not say an untaken module's resource is held if found: %q", steps[1].Why)
}
}
// both is a machine with a container runtime and ufw on it at once.
type both struct {
m *machine
u *ufwMachine
}
func (b *both) run(ctx context.Context, name string, args ...string) (string, error) {
switch name {
case "nft", "ufw", "iptables", "ip6tables", "firewall-cmd":
return b.u.run(ctx, name, args...)
}
return b.m.run(ctx, name, args...)
}
func ids(steps []Step) []string {
var out []string
for _, s := range steps {
if s.Type == "firewall" {
continue // said, not an outcome
}
out = append(out, s.ID)
}
return out
}
func outcomeIDs(r Report) []string {
var out []string
for _, o := range r.Outcomes {
out = append(out, o.ID)
}
return out
}
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// Defends novox/hq issue 104: the plan is the apply, said first — the same resources in the same
// order, and the one cutover ADR 0100 says must be previewed said as a cutover, not a hold.
func TestThePlanIsTheApplyInOrder(t *testing.T) {
dir := t.TempDir()
guard, page, keep, old := filepath.Join(dir, "guard.nft"), filepath.Join(dir, "index.html"),
filepath.Join(dir, "keep.html"), filepath.Join(dir, "old.conf")
for _, p := range []string{page, keep, old} {
write(t, p, "the predecessor's\n")
}
// Returning to adopted, with a guard to raise, an orphan, a hold that stays, a hold whose
// module is now taken, and a hold no longer declared.
back := adopted(t, `{"taken":["hello-web"],"untaken":{"keep":["keep.page"]}}`,
`{"id":"adoption.guard.table","type":"file","path":"`+guard+`","content":"table inet mesh-guard {}\n"},
{"id":"hello-web.page","type":"file","path":"`+page+`","content":"the mesh's page\n"},
{"id":"keep.page","type":"file","path":"`+keep+`","content":"the mesh's keep\n"}`)
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, DisabledByMesh: true, FoundAt: time.Now()}}
known.Record(store.Applied{ID: "old.conf", Type: "file", Target: old, Origin: store.OriginDeclared})
for id, module := range map[string]string{"hello-web.page": "hello-web", "keep.page": "keep", "gone.page": "gone"} {
known.RecordHeld(store.Held{ID: id, Module: module, Kind: "file", Target: filepath.Join(dir, id), Since: time.Now()})
}
fake := &both{m: &machine{containers: map[string]*fakeContainer{}}, u: &ufwMachine{installed: true}}
plan := Plan(back, known, store.OriginDeclared)
report, state, err := ApplyKeeping(context.Background(), archHost(t), back, known, store.OriginDeclared,
fake.run, nil, nil, KeepIn(dir))
if err != nil {
t.Fatal(err)
}
if got, want := verbs(plan), "enable ufw, forget gone.page, create adoption.guard.table, remove old.conf, "+
"create hello-web.page, hold keep.page"; got != want {
t.Errorf("planned %q, want %q", got, want)
}
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
t.Errorf("the plan said %q and the apply did %q", got, want)
}
taken := outcomeOf(report, "hello-web.page")
if !strings.Contains(taken.Detail, "taken") || !strings.Contains(plan[4].Why, "hello-web is taken: replaces what was found") {
t.Errorf("the cutover was applied as %q and planned as %q", taken.Detail, plan[4].Why)
}
if !fake.u.active || state.Firewall.DisabledByMesh {
t.Error("returning to adopted did not enable ufw again")
}
// Converged, from the mesh: an orphan, a new file, ufw retired, and what protected the node
// removed last.
flip := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"`+filepath.Join(dir, "a.conf")+`","content":"a\n"}]}`)
write(t, old, "again\n")
known = store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, FoundAt: time.Now()}}
known.Record(store.Applied{ID: "adoption.guard.table", Type: "file", Target: guard, Origin: store.OriginDeclared})
known.Record(store.Applied{ID: "old.conf", Type: "file", Target: old, Origin: store.OriginDeclared})
fake = &both{m: &machine{containers: map[string]*fakeContainer{}}, u: &ufwMachine{installed: true, active: true,
ruleset: "table inet mesh {\n}\n"}}
plan = Plan(flip, known, store.OriginDeclared)
report, state, err = ApplyKeeping(context.Background(), archHost(t), flip, known, store.OriginDeclared,
fake.run, nil, nil, KeepIn(dir))
if err != nil {
t.Fatal(err)
}
if got, want := verbs(plan), "remove old.conf, create a, disable ufw, remove adoption.guard.table"; got != want {
t.Errorf("planned %q, want %q", got, want)
}
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
t.Errorf("the plan said %q and the apply did %q", got, want)
}
if fake.u.active || !state.Firewall.DisabledByMesh {
t.Error("converging did not retire ufw")
}
}
func TestAResourceRunInAHeldContainerIsPlannedAsItIsApplied(t *testing.T) {
// A run-once step sharing a container's namespace runs in it, as an action's `in` does.
img := "example/x@sha256:0000000000000000000000000000000000000000000000000000000000000000"
d := parse(t, `{"declaration":1,"adoption":{"taken":["web"],"untaken":{"db":["db.server"]}},"resources":[
{"id":"web.server","type":"container","name":"web","image":"`+img+`"},
{"id":"db.server","type":"container","name":"db","image":"`+img+`"},
{"id":"db.init","type":"container","name":"db-init","image":"`+img+`","run-once":true,"network":"container:db"},
{"id":"web.warm","type":"container","name":"web-warm","image":"`+img+`","run-once":true,"network":"container:web"}]}`)
known := store.State{}
known.RecordHeld(store.Held{ID: "db.server", Module: "db", Kind: "container", Target: "db", Container: "predecessor"})
known.RecordHeld(store.Held{ID: "web.server", Module: "web", Kind: "container", Target: "web", Container: "predecessor"})
got := verbs(Plan(d, known, store.OriginDeclared))
// db is untaken, so what runs in it waits; web is taken, so its held container is replaced (the
// cutover) and what runs in it runs.
if got != "create web.server, hold db.server, hold db.init, create web.warm" {
t.Errorf("planned %q", got)
}
}
func TestAPlanSaysAContainerIsRecreatedWhenAFileItReadsChanged(t *testing.T) {
// The apply recreates a container when the content of a file it reads at creation changed
// (novox/hq 04-ISSUES/103); the plan says so from the record alone — what the container was
// created reading, against what this apply will write. And a container recorded before the
// host kept that record is accepted, so it is a check, not an update.
dir := t.TempDir()
env := filepath.Join(dir, "forge.env")
declare := func(port string) *declaration.Declaration {
return trusted(t, `{"declaration":1,"resources":[
{"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n"},
{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}]}`)
}
created := digestOf("DATABASE_PORT=5432\n")
known := store.State{}
known.Record(store.Applied{ID: "forge.env", Type: "file", Target: env, Wrote: created})
known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge",
Reads: map[string]string{env: created}})
if got := verbs(Plan(declare("5432"), known, store.OriginCarried)); got != "check forge.env, check forge.server" {
t.Errorf("nothing changed and the plan says %q", got)
}
steps := Plan(declare("5433"), known, store.OriginCarried)
if got := verbs(steps); got != "update forge.env, update forge.server" {
t.Fatalf("the env-file changes and the plan says %q", got)
}
if !strings.Contains(steps[1].Why, env+" changed") {
t.Errorf("the plan does not say which file: %+v", steps[1])
}
// No record of what it read: labelled by an earlier host, accepted as it is.
known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge"})
if got := verbs(Plan(declare("5433"), known, store.OriginCarried)); got != "update forge.env, check forge.server" {
t.Errorf("a container with no record of what it read is planned as %q", got)
}
}
func TestAPlanSaysWhichUnitsAnUndeclareGivesBackAndWhichItLeaves(t *testing.T) {
// novox/hq ADR 0118, said before it is done: "restore" only where removal may stop or disable
// something, and a unit whose file the mesh wrote named as the mesh's.
known := store.State{}
known.Record(store.Applied{ID: "guard-unit", Type: "file", Target: "/etc/systemd/system/mesh-guard.service"})
known.Record(store.Applied{ID: "guard", Type: "service", Target: "mesh-guard.service"})
known.Record(store.Applied{ID: "filter", Type: "service", Target: "filter.service",
Found: &store.FoundUnit{State: "stopped"}})
known.Record(store.Applied{ID: "boot", Type: "service", Target: "boot.service",
Found: &store.FoundUnit{State: "running", Boot: "disabled"}})
known.Record(store.Applied{ID: "runtime", Type: "service", Target: "docker.service",
Found: &store.FoundUnit{State: "running", Boot: "enabled"}})
known.Record(store.Applied{ID: "old", Type: "service", Target: "sshd.service"})
known.Record(store.Applied{ID: "nm", Type: "service", Target: "NetworkManager.service", Stateless: true})
steps := Plan(parse(t, nothingButA(t)), known, store.OriginCarried)
got := verbs(steps)
want := "forget nm, forget old, forget runtime, restore boot, restore filter, remove guard, remove guard-unit, create other"
if got != want {
t.Fatalf("planned %s\nwant %s", got, want)
}
for _, s := range steps {
switch s.ID {
case "guard":
if !strings.Contains(s.Why, "unit file") {
t.Errorf("the mesh's own unit was not named as the mesh's: %q", s.Why)
}
case "filter":
if !strings.Contains(s.Why, "found it stopped") {
t.Errorf("what the unit goes back to went unsaid: %q", s.Why)
}
case "old":
if !strings.Contains(s.Why, "left as it is") {
t.Errorf("a unit with nothing found was not said to be left: %q", s.Why)
}
}
}
}