Third review of mesh-catalog #147. A unit's name can now come from a setting (a template's instance), so every systemctl call that names a unit passes `--` before it, and a name can never be read as an option. On removal, a unit is started or enabled again only when it is exactly the stopped and disabled the mesh held it to. A masked unit, or one somebody started or enabled since, is left as it is, and the report says why. A restore that fails (the start refused, or the unit not running after it) puts back what this attempt changed and is reported as waiting, so no apply is made fatal by it and the next one tries again. The tests' fake service managers read systemctl's arguments in the shape they were written for (oldShape); internal/system's own test holds the `--`.
305 lines
14 KiB
Go
305 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) {
|
|
|
|
args = oldShape(name, args)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|