Files
mesh-host/internal/apply/restart_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

111 lines
4.4 KiB
Go

package apply
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// The resolver's configuration named a second file, the mesh's zones, declared after the service —
// as a module's facts are, after its resources. The host restarted the service for the
// configuration, the daemon could not read the zones file the same apply had not written yet, and
// the apply failed on every machine (novox/hq issue 260). A service is restarted only once every
// resource it names under restart-on has been applied.
func TestAServiceIsRestartedOnlyOnceEveryFileItNamesIsWritten(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "resolver.conf")
zones := filepath.Join(dir, "zones.conf")
declare := func(zonesContent string) string {
return fmt.Sprintf(`{"declaration":1,"resources":[
{"id":"resolver.config","type":"file","path":%q,"content":"conf-file=%s\n","mode":"0644"},
{"id":"resolver.service","type":"service","unit":"resolver.service","state":"running",
"restart-on":["resolver.config","resolver.fact-zones"]},
{"id":"resolver.fact-zones","type":"file","path":%q,"content":%q,"mode":"0644"}
]}`, conf, zones, zones, zonesContent)
}
// A daemon that cannot start without both of its files, as dnsmasq cannot.
var commands []string
services := recordingServices(&commands)
run := func(ctx context.Context, name string, args ...string) (string, error) {
args = oldShape(name, args)
if strings.Contains(strings.Join(args, " "), "start resolver.service") {
if _, err := os.Stat(zones); err != nil {
commands = append(commands, name+" "+strings.Join(args, " "))
return "", errors.New("cannot read " + zones + ": no such file or directory")
}
}
return services(ctx, name, args...)
}
report, state, err := Apply(context.Background(), archHost(t), parse(t, declare("server=/a/1\n")),
store.State{}, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatalf("the service was restarted before every file it reads was written: %v", err)
}
var detail string
for _, o := range report.Outcomes {
if o.ID == "resolver.service" {
detail = o.Detail
}
}
if !strings.Contains(detail, "resolver.config") || !strings.Contains(detail, "resolver.fact-zones") {
t.Errorf("one restart for both files was expected, the outcome says %q", detail)
}
restarts := 0
for _, c := range commands {
if strings.Contains(c, "stop resolver.service") {
restarts++
}
}
if restarts != 1 {
t.Errorf("the service was restarted %d times; once, after both files: %v", restarts, commands)
}
// And a change to the file declared after the service alone is acted on in the apply that
// makes it — not passed by because the service was reached first.
commands = nil
if _, _, err := Apply(context.Background(), archHost(t), parse(t, declare("server=/b/2\n")),
state, store.OriginCarried, run, nil, nil); err != nil {
t.Fatal(err)
}
restarted := false
for _, c := range commands {
if strings.Contains(c, "stop resolver.service") {
restarted = true
}
}
if !restarted {
t.Errorf("the zones file changed and the service was not restarted: %v", commands)
}
}
// Only a service moves, and only to just after the last resource it names — under restart-on or
// reload-on, in its own module or another's. Everything else keeps its declared place, and a name
// not in the declaration is no reason to move.
func TestAServiceIsOrderedAfterWhatItNamesAndNothingElseMoves(t *testing.T) {
d := parse(t, `{"declaration":1,"resources":[
{"id":"a.dir","type":"directory","path":"/tmp/a"},
{"id":"a.runtime","type":"service","unit":"docker.service","state":"running","reload-on":["b.daemon"]},
{"id":"a.svc","type":"service","unit":"a.service","state":"running","restart-on":["a.conf","gone"]},
{"id":"a.conf","type":"file","path":"/tmp/a/conf","content":"x\n"},
{"id":"b.daemon","type":"file","path":"/tmp/b/daemon.json","content":"{}\n"},
{"id":"b.after","type":"file","path":"/tmp/b/after","content":"y\n"},
{"id":"b.svc","type":"service","unit":"b.service","state":"running","restart-on":["a.conf"]}
]}`)
var got []string
for _, r := range afterWhatTheyRead(d.Resources) {
got = append(got, r.Identity())
}
want := "a.dir a.conf a.svc b.daemon a.runtime b.after b.svc"
if strings.Join(got, " ") != want {
t.Errorf("ordered\n %s\nwant\n %s", strings.Join(got, " "), want)
}
}