A declaration is JSON, versioned, and an ordered list of resources with stable identities (novox/hq ADR 0043). The vocabulary is directory, file and service, and anything outside it — an unknown version, type or field — refuses the WHOLE declaration. A host that skipped what it did not understand would apply most of what it was sent and report success. It converges rather than executes: applying twice changes nothing the second time, and applying to a drifted machine returns it. A mode is maintained rather than set, because a permission applied at creation is not a permission held — this repository has paid for that once already. It owns a footprint and only that. What it applied and is no longer declared is removed; what it did not create is never touched. Removal runs FIRST, because a resource leaving a declaration while another arrives at the same path is an ordinary rename, and removing afterwards would delete the file just written. The store arrives here rather than at stage 3, as ADR 0043 predicted: nothing can be removed without knowing what was applied. It is written atomically, refuses to start empty when it exists and cannot be read — believing it owns nothing would leave everything behind forever — and is saved even when an apply fails, because what was applied before the failure is on the machine either way. Three faults found by running inside a raised machine rather than by reasoning: A unit that DOES NOT EXIST reads as `inactive` from `systemctl is-active`, exactly as a stopped one does. So declaring a unit stopped reported success for a unit the host cannot manage at all — absence read as satisfaction, which is 04-ISSUES/007 wearing a different hat. LoadState separates them. Removing an orphaned service whose unit has since been uninstalled failed the whole apply, and a host holding such a record could then apply NOTHING, ever, with no way out but editing its state by hand. Removal is now idempotent for the same reason os.RemoveAll is. And the flag parser was wrong in the same way twice: fixing `mesh-host inventory --json` by taking the subcommand off the front left `mesh-host apply decl.json --dry-run` broken identically, because the standard library stops at the first non-flag argument wherever that argument is. Parsed in a loop now. 30 new tests, 55 in total.
414 lines
15 KiB
Go
414 lines
15 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// Each test names the decision it defends (novox/hq ADR 0034).
|
|
|
|
func parse(t *testing.T, raw string) *declaration.Declaration {
|
|
t.Helper()
|
|
d, err := declaration.Parse([]byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("fixture is not a valid declaration: %v", err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// noServices refuses to run anything. Used where a test declares no services, so that a test
|
|
// which accidentally reaches the service manager fails loudly instead of passing quietly.
|
|
func noServices(context.Context, string, ...string) (string, error) {
|
|
return "", errors.New("this test declares no services and should not have run a command")
|
|
}
|
|
|
|
func TestApplyingTwiceChangesNothingTheSecondTime(t *testing.T) {
|
|
// Idempotence is what makes an apply safe to run on a schedule. Without it, a host that
|
|
// reconciles every few minutes rewrites files forever and every reader sees churn.
|
|
dir := t.TempDir()
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"d","type":"directory","path":"`+dir+`/etc","mode":"0755"},
|
|
{"id":"f","type":"file","path":"`+dir+`/etc/a.conf","content":"hello\n","mode":"0640"}
|
|
]}`)
|
|
|
|
first, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !first.Changed() {
|
|
t.Fatal("the first apply on an empty machine changed nothing")
|
|
}
|
|
|
|
second, _, err := Apply(context.Background(), d, state, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.Changed() {
|
|
t.Errorf("the second apply changed something: %+v", second.Outcomes)
|
|
}
|
|
}
|
|
|
|
func TestADriftedMachineIsReturned(t *testing.T) {
|
|
// The other half of idempotence, and the half that matters: converging is not "do nothing
|
|
// if the state file says it was done". The machine is read, not the record.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "a.conf")
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"`+path+`","content":"correct\n","mode":"0644"}
|
|
]}`)
|
|
|
|
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte("someone edited this\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, _, err := Apply(context.Background(), d, state, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !report.Changed() {
|
|
t.Fatal("a drifted file was left drifted")
|
|
}
|
|
got, _ := os.ReadFile(path)
|
|
if string(got) != "correct\n" {
|
|
t.Errorf("the file was not returned: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestADroppedResourceIsRemoved(t *testing.T) {
|
|
// novox/hq ADR 0043: the host removes what it previously applied and is no longer
|
|
// declared. Removing a line from a declaration is an act with an effect.
|
|
dir := t.TempDir()
|
|
keep := filepath.Join(dir, "keep.conf")
|
|
drop := filepath.Join(dir, "drop.conf")
|
|
|
|
both := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"},
|
|
{"id":"drop","type":"file","path":"`+drop+`","content":"b\n"}
|
|
]}`)
|
|
_, state, err := Apply(context.Background(), both, store.State{}, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
one := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"keep","type":"file","path":"`+keep+`","content":"a\n"}
|
|
]}`)
|
|
report, state, err := Apply(context.Background(), one, state, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := os.Stat(drop); !errors.Is(err, os.ErrNotExist) {
|
|
t.Error("a resource dropped from the declaration was left on the machine")
|
|
}
|
|
if _, err := os.Stat(keep); err != nil {
|
|
t.Error("a declared resource was removed")
|
|
}
|
|
if _, still := state.Find("drop"); still {
|
|
t.Error("the host still believes it owns what it removed")
|
|
}
|
|
if report.Outcomes[0].Action != "removed" {
|
|
t.Errorf("removal is not reported first: %+v", report.Outcomes)
|
|
}
|
|
}
|
|
|
|
func TestNothingTheHostDidNotCreateIsTouched(t *testing.T) {
|
|
// The boundary the whole removal rule turns on. A machine has things on it the mesh did
|
|
// not put there, and a converger that treats "not declared" as "must not exist" deletes
|
|
// them. Authoritative over its own footprint; inert everywhere else.
|
|
dir := t.TempDir()
|
|
stranger := filepath.Join(dir, "not-ours.conf")
|
|
if err := os.WriteFile(stranger, []byte("someone else's\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"ours","type":"file","path":"`+filepath.Join(dir, "ours.conf")+`","content":"a\n"}
|
|
]}`)
|
|
if _, _, err := Apply(context.Background(), d, store.State{}, noServices, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := os.ReadFile(stranger)
|
|
if err != nil || string(got) != "someone else's\n" {
|
|
t.Error("a file the host did not create was removed or changed")
|
|
}
|
|
}
|
|
|
|
func TestARenameToTheSamePathDoesNotDeleteTheNewFile(t *testing.T) {
|
|
// Why removal happens FIRST. A resource leaving a declaration while another arrives at the
|
|
// same path is an ordinary rename; removing afterwards would delete the file just written.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "shared.conf")
|
|
|
|
before := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"old","type":"file","path":"`+path+`","content":"old\n"}
|
|
]}`)
|
|
_, state, err := Apply(context.Background(), before, store.State{}, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
after := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"new","type":"file","path":"`+path+`","content":"new\n"}
|
|
]}`)
|
|
if _, _, err := Apply(context.Background(), after, state, noServices, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("the renamed resource is gone: %v", err)
|
|
}
|
|
if string(got) != "new\n" {
|
|
t.Errorf("content is %q, want the new one", got)
|
|
}
|
|
}
|
|
|
|
func TestAFailedStepFailsTheApply(t *testing.T) {
|
|
// novox/hq ADR 0008. And the error carries what HAD been done, because the machine is in
|
|
// whatever state the apply reached and the only honest thing to hand back is that list.
|
|
dir := t.TempDir()
|
|
blocker := filepath.Join(dir, "blocker")
|
|
if err := os.WriteFile(blocker, []byte("i am a file\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"fine","type":"file","path":"`+filepath.Join(dir, "fine.conf")+`","content":"a\n"},
|
|
{"id":"doomed","type":"directory","path":"`+blocker+`"},
|
|
{"id":"never","type":"file","path":"`+filepath.Join(dir, "never.conf")+`","content":"b\n"}
|
|
]}`)
|
|
|
|
_, _, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
|
if err == nil {
|
|
t.Fatal("an impossible resource did not fail the apply")
|
|
}
|
|
|
|
var applyErr *Error
|
|
if !errors.As(err, &applyErr) {
|
|
t.Fatalf("expected an apply error, got %T", err)
|
|
}
|
|
if applyErr.Resource != "doomed" {
|
|
t.Errorf("the failure names %q, not the resource that failed", applyErr.Resource)
|
|
}
|
|
if len(applyErr.Done.Outcomes) != 1 {
|
|
t.Errorf("the error does not carry what was already applied: %+v", applyErr.Done.Outcomes)
|
|
}
|
|
// And nothing after the failure ran.
|
|
if _, err := os.Stat(filepath.Join(dir, "never.conf")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Error("the apply continued past a failure")
|
|
}
|
|
}
|
|
|
|
func TestNothingIsRecordedUntilItWorked(t *testing.T) {
|
|
// novox/hq ADR 0035. A record written before the fact restates the request in a new place
|
|
// and inherits none of the authority of having happened.
|
|
dir := t.TempDir()
|
|
blocker := filepath.Join(dir, "blocker")
|
|
if err := os.WriteFile(blocker, []byte("x\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"doomed","type":"directory","path":"`+blocker+`"}
|
|
]}`)
|
|
|
|
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
|
if err == nil {
|
|
t.Fatal("expected a failure")
|
|
}
|
|
if _, claimed := state.Find("doomed"); claimed {
|
|
t.Error("the host recorded owning something it failed to apply")
|
|
}
|
|
}
|
|
|
|
func TestAModeIsMaintainedNotJustSet(t *testing.T) {
|
|
// A permission set at creation is not a permission maintained — this repository has
|
|
// already paid for that once, with generated files left world-readable because the mode
|
|
// applied only when the file was first written.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "secret.conf")
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"`+path+`","content":"s\n","mode":"0600"}
|
|
]}`)
|
|
|
|
_, state, err := Apply(context.Background(), d, store.State{}, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0o666); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, _, err := Apply(context.Background(), d, state, noServices, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, _ := os.Stat(path)
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Errorf("mode is %o after reconciling, want 0600", info.Mode().Perm())
|
|
}
|
|
if !report.Changed() {
|
|
t.Error("a mode that had drifted was reported as unchanged")
|
|
}
|
|
}
|
|
|
|
func TestAServiceIsReadBackNotAssumed(t *testing.T) {
|
|
// `systemctl start` returning zero says the transaction was accepted, not that the unit is
|
|
// running. A unit that starts and immediately dies satisfies the command.
|
|
started := false
|
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
if args[0] == "show" {
|
|
if started {
|
|
return "LoadState=loaded\nActiveState=failed\n", nil // started, then died
|
|
}
|
|
return "LoadState=loaded\nActiveState=inactive\n", nil
|
|
}
|
|
started = true
|
|
return "", nil // `systemctl start` succeeds
|
|
}
|
|
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"s","type":"service","unit":"doomed.service","state":"running"}
|
|
]}`)
|
|
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
|
if err == nil {
|
|
t.Fatal("a service that died immediately was reported as running")
|
|
}
|
|
if !strings.Contains(err.Error(), "asked to be running and is stopped") {
|
|
t.Errorf("the failure does not say what was observed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
|
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
return "LoadState=loaded\nActiveState=reticent\n", nil
|
|
}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"s","type":"service","unit":"odd.service","state":"running"}
|
|
]}`)
|
|
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "neither running nor stopped") {
|
|
t.Errorf("an unrecognised service state was not refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestADroppedServiceIsStoppedNotDeleted(t *testing.T) {
|
|
// The host did not install the unit and does not own the unit file — only the state it put
|
|
// the unit into.
|
|
var commands []string
|
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
commands = append(commands, strings.Join(args, " "))
|
|
if args[0] == "show" {
|
|
return "LoadState=loaded\nActiveState=active\n", nil
|
|
}
|
|
return "", nil
|
|
}
|
|
state := store.State{Resources: []store.Applied{
|
|
{ID: "s", Type: "service", Target: "gone.service"},
|
|
}}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
|
]}`)
|
|
|
|
if _, _, err := Apply(context.Background(), d, state, run, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joined := strings.Join(commands, "; ")
|
|
if !strings.Contains(joined, "stop gone.service") {
|
|
t.Errorf("the dropped service was not stopped: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "disable") || strings.Contains(joined, "mask") {
|
|
t.Errorf("the host did more than stop a unit it does not own: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestAUnitThatDoesNotExistIsNotStopped(t *testing.T) {
|
|
// Found by applying inside a raised machine. `systemctl is-active` says "inactive" for a
|
|
// unit that DOES NOT EXIST exactly as it does for one that is installed and stopped, so
|
|
// declaring a unit stopped reported success for a unit the host cannot manage at all.
|
|
//
|
|
// Absence read as satisfaction — 04-ISSUES/007 wearing a different hat, and the mirror of
|
|
// the degraded-init bug the capability detector had.
|
|
absent := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
return "LoadState=not-found\nActiveState=inactive\n", nil
|
|
}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"s","type":"service","unit":"never-installed.service","state":"stopped"}
|
|
]}`)
|
|
|
|
_, state, err := Apply(context.Background(), d, store.State{}, absent, nil)
|
|
if err == nil {
|
|
t.Fatal("a unit that does not exist was reported as satisfactorily stopped")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not exist on this machine") {
|
|
t.Errorf("the failure does not say the unit is absent: %v", err)
|
|
}
|
|
if _, claimed := state.Find("s"); claimed {
|
|
t.Error("the host recorded owning a unit that is not installed")
|
|
}
|
|
}
|
|
|
|
func TestAMaskedUnitIsRefused(t *testing.T) {
|
|
// Masked means someone deliberately made it unstartable. Applying over that would undo a
|
|
// decision the host did not make and cannot see the reason for.
|
|
masked := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
return "LoadState=masked\nActiveState=inactive\n", nil
|
|
}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"s","type":"service","unit":"masked.service","state":"running"}
|
|
]}`)
|
|
if _, _, err := Apply(context.Background(), d, store.State{}, masked, nil); err == nil {
|
|
t.Fatal("a masked unit was accepted")
|
|
}
|
|
}
|
|
|
|
func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) {
|
|
// Found on a real machine. Removing an orphaned service runs `systemctl stop`, which fails
|
|
// when the unit no longer exists — and a failure there fails the whole apply. A host
|
|
// holding a record of an uninstalled unit could then apply NOTHING, ever, with no way out
|
|
// but editing its state by hand.
|
|
//
|
|
// Removal is idempotent for the same reason os.RemoveAll is: the desired end state is
|
|
// already true.
|
|
var stopped bool
|
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
if args[0] == "show" {
|
|
return "LoadState=not-found\nActiveState=inactive\n", nil
|
|
}
|
|
stopped = true
|
|
return "", errors.New("systemctl exited 5: Unit not loaded")
|
|
}
|
|
known := store.State{Resources: []store.Applied{
|
|
{ID: "gone", Type: "service", Target: "uninstalled.service"},
|
|
}}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
|
]}`)
|
|
|
|
report, state, err := Apply(context.Background(), d, known, run, nil)
|
|
if err != nil {
|
|
t.Fatalf("a vanished unit stranded the apply: %v", err)
|
|
}
|
|
if stopped {
|
|
t.Error("the host tried to stop a unit that does not exist")
|
|
}
|
|
if _, still := state.Find("gone"); still {
|
|
t.Error("the host still believes it owns a unit that is gone")
|
|
}
|
|
if report.Outcomes[0].Action != "removed" {
|
|
t.Errorf("the vanished unit was not reported as removed: %+v", report.Outcomes)
|
|
}
|
|
}
|