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) } } } }