Files
mesh-host/internal/apply/removal_order_test.go
T
jochen a018ebd1ca
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Pass -- before every unit, and give a unit back only when it is exactly as the mesh held it
Third review of mesh-catalog #147.

A unit's name can now come from a setting (a template's instance), so every systemctl call that names a
unit passes `--` before it, and a name can never be read as an option.

On removal, a unit is started or enabled again only when it is exactly the stopped and disabled the mesh
held it to. A masked unit, or one somebody started or enabled since, is left as it is, and the report
says why. A restore that fails (the start refused, or the unit not running after it) puts back what this
attempt changed and is reported as waiting, so no apply is made fatal by it and the next one tries again.

The tests' fake service managers read systemctl's arguments in the shape they were written for
(oldShape); internal/system's own test holds the `--`.
2026-10-09 01:54:26 +02:00

121 lines
5.6 KiB
Go

package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// The contract at store.State.Orphans, held: a module's resources are removed newest record first, so
// a process recorded before the configuration it reads is stopped after that configuration is gone.
// mesh-catalog's nfs-server relies on it (novox/hq ADR 0263): its `exports` process, declared before
// its config directory and file, finds the configuration gone when it is stopped on unassignment and
// only then removes its export file; on a restart or a push the configuration is still there and it
// leaves the exports alone.
func TestAnUnassignedModulesProcessIsStoppedAfterTheConfigurationItReads(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
etc := filepath.Join(t.TempDir(), "nfs-server")
config := filepath.Join(etc, "shares.conf")
body, digest := anArchive(t, map[string]string{"nfs-server": "#!/bin/sh\n"})
process := `{"id":"nfs-server.exports","type":"process","name":"nfs-server-exports","source":"` + serving(t, body) +
`","digest":"` + digest + `","run":["./nfs-server","exports"],"restart-on":["nfs-server.config"]}`
// In the manifest's order: the process first, then the directory and the file it reads.
module := process + `,
{"id":"nfs-server.config-dir","type":"directory","path":"` + etc + `","mode":"0755"},
{"id":"nfs-server.config","type":"file","path":"` + config + `","content":"shares=library=/srv/library\n","mode":"0644"}`
other := `{"id":"other.f","type":"file","path":"` + filepath.Join(t.TempDir(), "other") + `","content":"o\n"}`
var configAtStop []bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "systemctl" && strings.Join(args, " ") == "disable --now nfs-server-exports.service" {
_, err := os.Stat(config)
configAtStop = append(configAtStop, err == nil)
}
return "", nil
}
_, state, err := Apply(context.Background(), archHost(t), declare(t, module+","+other), store.State{}, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
// The record keeps the declared order: a process is not moved after what it restarts on (only a
// service is, by afterWhatTheyRead), and a record replaced on a later apply keeps its place.
var order []string
for _, r := range state.Resources {
order = append(order, r.ID)
}
if strings.Join(order, " ") != "nfs-server.exports nfs-server.config-dir nfs-server.config other.f" {
t.Fatalf("recorded in the order %v, not the declared one", order)
}
if _, state, err = Apply(context.Background(), archHost(t), declare(t, module+","+other), state, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
report, _, err := Apply(context.Background(), archHost(t), declare(t, other), state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(configAtStop) != 1 || configAtStop[0] {
t.Errorf("the process was stopped while its configuration was still there (seen at each stop: %v)", configAtStop)
}
var removed []string
for _, o := range report.Outcomes {
if o.Action == "removed" {
removed = append(removed, o.ID)
}
}
if strings.Join(removed, " ") != "nfs-server.config nfs-server.config-dir nfs-server.exports" {
t.Errorf("removed in the order %v; the newest record goes first", removed)
}
}
// What the contract does not promise: a resource first recorded after the module's other resources —
// one added in a later version of the module, or one whose first apply failed — is appended, so it
// is removed first. A module that relies on the order declares the process from its first version and
// keeps it applying; the test shows the flip so nobody reads the contract as stronger than it is.
func TestAProcessRecordedAfterItsConfigurationIsStoppedFirst(t *testing.T) {
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
config := filepath.Join(t.TempDir(), "shares.conf")
body, digest := anArchive(t, map[string]string{"nfs-server": "#!/bin/sh\n"})
process := `{"id":"m.exports","type":"process","name":"m-exports","source":"` + serving(t, body) +
`","digest":"` + digest + `","run":["./nfs-server","exports"],"restart-on":["m.config"]}`
file := `{"id":"m.config","type":"file","path":"` + config + `","content":"x\n","mode":"0644"}`
other := `{"id":"other.f","type":"file","path":"` + filepath.Join(t.TempDir(), "other") + `","content":"o\n"}`
var configAtStop []bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if name == "systemctl" && strings.Join(args, " ") == "disable --now m-exports.service" {
_, err := os.Stat(config)
configAtStop = append(configAtStop, err == nil)
}
return "", nil
}
_, state, err := Apply(context.Background(), archHost(t), declare(t, file+","+other), store.State{}, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if _, state, err = Apply(context.Background(), archHost(t), declare(t, process+","+file+","+other), state, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if _, _, err = Apply(context.Background(), archHost(t), declare(t, other), state, store.OriginDeclared, run, nil, nil); err != nil {
t.Fatal(err)
}
if len(configAtStop) != 1 || !configAtStop[0] {
t.Errorf("a process recorded after its configuration was not stopped first (seen at each stop: %v)", configAtStop)
}
}