Files
mesh-host/internal/apply/restart_order_test.go
T
jochen 101f0c0d61 Restart a service only after every file it names is written (hq issue 260)
A service was restarted where it was declared, so one declared ahead of a
file in its restart-on was restarted before that file existed (the
resolver's zones file, a fact appended after its module's resources), and
a later change to such a file was never acted on. Each service is now
applied right after the last resource it names under restart-on or
reload-on; nothing else moves.
2026-10-05 21:55:47 +02:00

110 lines
4.3 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) {
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)
}
}