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.
303 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|