Review of the fix for hq issue 104 found three faults in it. A file applied
on an enrolled node — the mesh's own last declaration included — is applied
as the bundle is, so its resources are recorded as the machine's own and
what the mesh declared reads as undeclared: the plan removed the foundation.
`apply FILE` is for a machine the mesh has not spoken to, and is now refused
saying so whenever declared.json exists. The plan looked at what is held
before what the declaration says is taken, so the one cutover ADR 0100 says
must be previewed read as a hold; it now decides in holdOnAdopted's order,
models a step run inside a held container, and a test holds the plan's
sequence to the apply's outcomes. Genesis wrote the mode on every run, so a
re-run after `converge` left the state saying adopted while the kept,
signed declaration said converged, and the reconcile loop refused every five
minutes with no delivery coming to end it: genesis now writes the mode only
when none is recorded, and where the state and the verified kept declaration
disagree, the kept declaration wins and the repair is said.
Also: a file lock beside the state, taken by the link service, the host's
own commands and the installer alike, so a `reconcile` run by hand no
longer races the loop's save — chosen over refusing while a named service is
active, which would miss a `mesh-host run` started by hand; `--json
--dry-run` emits {plan} like an apply emits {plan, report}; the README's
duplicate flag line; and the bundle refusal is about the digest, not a claim
the carried bytes can never match what genesis applied.
228 lines
10 KiB
Go
228 lines
10 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)
|
|
}
|
|
}
|