An operator ran `mesh-host reconcile` on an adopted control-node with twelve modules assigned. It applied the bundle the host carries — the genesis declaration, foundation only, converged: recreated the store, failed on the broker's held port, wrote the converged base filter and started its service, and stopped at the first failing action. The filter closed the machine for forty-five minutes. The host reported the node adopted in every report, the declaration said converged, and nothing compared the two; nothing was printed before acting (hq issue 104). The host now records the node's mode — from every declaration the mesh sends, and at genesis from what the operator said — and refuses, at the point of application, a declaration that says the other mode, naming both and the act that changes it. Only a declaration the link delivers, signed, changes the mode: that is how `converge` and `adopt` arrive, so the flip still works and nothing else can do it. Genesis marks the bundle consumed, with the digest of what it applied, so `reconcile` holds a node the mesh has spoken to against what the mesh last said and never the bundle, and refuses the carried bytes when they are not what genesis applied. A file is refused when it is not what the mesh last said: a declaration carries no sequence and no issued-at, so the host cannot tell older from newer, and says so. Both commands print what they would change — a hold, a removal, an action named as one — before touching anything, and --dry-run is that list and nothing more.
95 lines
4.1 KiB
Go
95 lines
4.1 KiB
Go
package apply
|
|
|
|
import (
|
|
"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)
|
|
}
|
|
}
|