package apply import ( "context" "os" "path/filepath" "strings" "testing" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" ) func aProcess() *declaration.Process { return &declaration.Process{ ID: "server", Type: declaration.TypeProcess, Name: "greeter", Source: "https://store.invalid/greeter/daemon", Digest: "sha256:" + strings.Repeat("a", 64), Run: []string{"node", "index.js"}, Env: map[string]string{"MESH_NODE": "anchor", "A_FIRST": "1"}, } } // The unit the mesh writes says what it runs, where, and that it comes back. func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) { unit := unitFor(aProcess()) for _, want := range []string{ "ExecStart=node index.js", "WorkingDirectory=/var/lib/mesh/daemons/greeter", "Restart=always", "WantedBy=multi-user.target", } { if !strings.Contains(unit, want) { t.Fatalf("the unit does not say %q:\n%s", want, unit) } } } // **Generated whole and saying so.** Every managed file on a machine carries this, because an edit // that survives until the next declaration and then vanishes is worse than one that is refused. func TestTheUnitSaysItIsTheMeshs(t *testing.T) { unit := unitFor(aProcess()) if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") { t.Fatalf("the unit does not say it is generated:\n%s", unit) } } // **Deterministic, because the unit is half the daemon's identity.** Environment held in a map // would be written in Go's iteration order, so every apply would see a different unit and call an // unchanged daemon changed — restarting it on every declaration for ever. func TestTheUnitIsTheSameEveryTime(t *testing.T) { first := unitFor(aProcess()) for i := 0; i < 20; i++ { if again := unitFor(aProcess()); again != first { t.Fatalf("two renderings of one daemon differ:\n%s\n---\n%s", first, again) } } // And sorted, so the order is a decision rather than luck. if strings.Index(first, "A_FIRST") > strings.Index(first, "MESH_NODE") { t.Fatalf("environment is not in a stable order:\n%s", first) } } // **Two daemons from one bundle differing only in their command are different daemons.** Tracking // the digest alone would call the second one unchanged and leave the first one running. func TestAProcesssIdentityIncludesHowItIsRun(t *testing.T) { one := aProcess() two := aProcess() two.Run = []string{"node", "other.js"} if unitFor(one) == unitFor(two) { t.Fatal("two daemons with different commands render one unit, so a change would be missed") } } // A process that runs as somebody says so, and one that does not says nothing — rather than naming // root explicitly, which would be a claim the mesh does not need to make. func TestAProcessRunsAsWhoItSaid(t *testing.T) { as := aProcess() as.User = "greeter" if !strings.Contains(unitFor(as), "User=greeter") { t.Fatalf("the unit does not run as the user it named:\n%s", unitFor(as)) } if strings.Contains(unitFor(aProcess()), "User=") { t.Fatalf("a process that named no user had one written for it:\n%s", unitFor(aProcess())) } } // **Three modes, one kind.** A scheduled process is a timer plus a unit that finishes, not a unit // that stays up — and the difference has to be in what is written, or a schedule becomes a second // copy running continuously between fires. func TestAScheduledProcessRunsOnItsCadenceRatherThanContinuously(t *testing.T) { every := aProcess() every.Schedule = "0 3 * * *" unit := unitFor(every) if strings.Contains(unit, "Restart=always") { t.Fatalf("a scheduled process is restarted whenever it exits, so it never stops:\n%s", unit) } if !strings.Contains(unit, "Type=oneshot") { t.Fatalf("a scheduled process is not a step that finishes:\n%s", unit) } timer := timerFor(every) if !strings.Contains(timer, "OnCalendar=") { t.Fatalf("a scheduled process has no cadence:\n%s", timer) } // A fire missed while the machine was off happens when it returns, rather than being skipped — // the difference between a machine that was down and a schedule that quietly stopped. if !strings.Contains(timer, "Persistent=true") { t.Fatalf("a missed fire is skipped silently:\n%s", timer) } } // Five-field cron becomes what a timer reads, rather than the host waking to decide. func TestACronBecomesATimersCalendar(t *testing.T) { for cron, want := range map[string]string{ "0 3 * * *": "*-*-* 3:0:00", "30 4 1 * *": "*-*-1 4:30:00", "0 0 * * mon": "mon *-*-* 0:0:00", } { if got := calendarFor(cron); got != want { t.Fatalf("%q became %q rather than %q", cron, got, want) } } } // And the long-running mode is unchanged by any of it: it stays up and comes back. func TestAProcessThatStaysUpIsStillRestartedWhenItExits(t *testing.T) { unit := unitFor(aProcess()) if !strings.Contains(unit, "Restart=always") { t.Fatalf("a process that should stay up is not restarted when it exits:\n%s", unit) } if strings.Contains(unit, "Type=oneshot") { t.Fatalf("a process that should stay up is declared a step:\n%s", unit) } } // **A unit file reinterprets a value in three ways nothing else does**, and a module's environment // routinely contains all three — a generated password is arbitrary bytes. func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) { // A percent begins a specifier: %H is the hostname. A password containing one would be // silently replaced, failing as an authentication error nobody can explain from the // declaration. percent := aProcess() percent.Env = map[string]string{"PASSWORD": "a%Hb"} if !strings.Contains(unitFor(percent), "%%H") { t.Fatalf("a percent was left as a systemd specifier:\n%s", unitFor(percent)) } // Whitespace separates assignments: unquoted, K=a b sets K to "a" and reads "b" as another. spaced := aProcess() spaced.Env = map[string]string{"GREETING": "hello there"} if !strings.Contains(unitFor(spaced), `"GREETING=hello there"`) { t.Fatalf("a value with a space was not quoted:\n%s", unitFor(spaced)) } // A quote would end the quoting early, and what follows would be read as unit syntax. quoted := aProcess() quoted.Env = map[string]string{"TOKEN": `a"b`} line := "" for _, l := range strings.Split(unitFor(quoted), "\n") { if strings.HasPrefix(l, "Environment=") { line = l } } if !strings.Contains(line, `\"`) { t.Fatalf("a quote was not escaped, so the value ends early: %s", line) } } func TestAnUndeclaredProcessIsRemovedWithItsUnitAndBundle(t *testing.T) { // novox/hq ADR 0118: a process's unit and bundle are the host's own, so they go with the // declaration. Before, there was no way to remove a process at all, and one left undeclared // failed every apply on its node. units, bundles := t.TempDir(), t.TempDir() wasUnits, wasBundles := unitDir, daemonRoot unitDir, daemonRoot = units, bundles t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles }) for _, f := range []string{"mesh-job.service", "mesh-job.timer"} { if err := os.WriteFile(filepath.Join(units, f), []byte("[Unit]\n"), 0o644); err != nil { t.Fatal(err) } } if err := os.MkdirAll(filepath.Join(bundles, "mesh-job", "bin"), 0o755); err != nil { t.Fatal(err) } var commands []string run := func(ctx context.Context, name string, args ...string) (string, error) { commands = append(commands, strings.Join(args, " ")) return "", nil } known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: "mesh-job"}}} d := parse(t, `{"declaration":1,"resources":[ {"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"} ]}`) report, after, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil) if err != nil { t.Fatalf("an undeclared process failed the apply: %v", err) } joined := strings.Join(commands, "; ") timer, service := strings.Index(joined, "disable --now mesh-job.timer"), strings.Index(joined, "disable --now mesh-job.service") if timer < 0 || service < 0 || timer > service { t.Errorf("the timer and the unit were not stopped, timer first: %s", joined) } if !strings.Contains(joined, "daemon-reload") { t.Errorf("the service manager was not told its units changed: %s", joined) } for _, gone := range []string{filepath.Join(units, "mesh-job.service"), filepath.Join(units, "mesh-job.timer"), filepath.Join(bundles, "mesh-job")} { if _, err := os.Stat(gone); !os.IsNotExist(err) { t.Errorf("%s is still there", gone) } } if o := outcomeOf(report, "p"); o.Action != "removed" { t.Errorf("outcome %+v, want removed", o) } if _, still := after.Find("p"); still { t.Error("the host still believes it owns the process") } // Again, with everything already gone: forgotten, not an error. _, _, err = Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil) if err != nil { t.Errorf("removing a process that is already gone failed: %v", err) } } func TestAProcessRecordedUnderAPathlikeNameIsRefusedNotRemoved(t *testing.T) { // filepath.Join(daemonRoot, "..") is the mesh's own directory, and removal deletes what that // names, whole. A record is what some host wrote, perhaps under looser rules than today's, so // the removal holds the name to the declaration's rule again (novox/hq ADR 0118). root := t.TempDir() bundles := filepath.Join(root, "daemons") wasUnits, wasBundles := unitDir, daemonRoot unitDir, daemonRoot = t.TempDir(), bundles t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles }) precious := filepath.Join(root, "state.json") if err := os.MkdirAll(filepath.Join(bundles, "other"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(precious, []byte("{}"), 0o600); err != nil { t.Fatal(err) } for _, name := range []string{"..", ".", "", "-x"} { known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: name}}} d := parse(t, `{"declaration":1,"resources":[ {"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"} ]}`) if _, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, noServices, nil, nil); err == nil { t.Errorf("a process recorded as %q was removed by name", name) } for _, still := range []string{precious, filepath.Join(bundles, "other")} { if _, err := os.Stat(still); err != nil { t.Fatalf("removing a process recorded as %q took %s with it", name, still) } } } }