Files
mesh-host/internal/apply/process_test.go
T
jochen 08c0f40ff0 review: refuse a process named ".", ".." or with a leading dash, so removing one cannot delete the mesh's own directory (hq ADR 0118)
removeProcess deletes filepath.Join(daemonRoot, name) whole; a process named ".." made that
/var/lib/mesh. The declaration and the removal now hold the name to one rule.
2026-09-27 00:41:02 +02:00

259 lines
10 KiB
Go

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)
}
}
}
}