A container reflects its config: restart-on for containers (04-ISSUES/009)

A container reads a mounted file once, at start; its spec (image, env, volumes)
does not include a mounted file's content, so a settings change that re-renders the
file left the running process holding the old value while every check passed. Give
Container the restart-on field a Service already has, and recreate the container
when a named resource changed this pass. Unit-tested (recreated on change, left
alone otherwise) and proven in the mesh-lab: a running grafana runtime picked up a
token change on the next push.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-04 23:39:47 +02:00
parent 8211d8b6fb
commit aa441bac19
3 changed files with 100 additions and 11 deletions
+61
View File
@@ -730,6 +730,67 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
}
}
func TestAContainerIsRecreatedWhenARestartOnResourceChanged(t *testing.T) {
// A container reads a mounted file once, at start. When the file changed this pass but the
// container's spec did not, the plain "spec matches, leave it" rule would keep the process
// holding the old value for ever, with every check passing (novox/hq 04-ISSUES/009). A
// container names the resources it must reflect in restart-on, the same as a service, and the
// host recreates it. Here the config file is fresh, so it is written this pass, and the
// already-running-and-matching container must still be replaced.
dir := t.TempDir()
conf := filepath.Join(dir, "config.json")
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"config","type":"file","path":"`+conf+`","content":"{\"token\":\"new\"}\n"},
{"id":"app","type":"container","name":"app","image":"`+pinned+`","restart-on":["config"]}
]}`)
spec := containerSpec(d.Resources[1].(*declaration.Container))
var removed, created bool
run := func(ctx context.Context, name string, args ...string) (string, error) {
switch args[0] {
case "info":
return "27.0\n", nil
case "inspect":
// The container already exists, running, with exactly the spec it is declared with —
// only the mounted file changed.
return "true\t" + spec, nil
case "rm":
removed = true
return "", nil
case "run":
created = true
return "deadbeef\n", nil
}
return "", nil
}
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !removed || !created {
t.Fatalf("a container was not recreated when its restart-on file changed (removed=%v created=%v)", removed, created)
}
app := report.Outcomes[len(report.Outcomes)-1]
if app.Action != "updated" || !strings.Contains(app.Detail, "config") {
t.Errorf("the recreation did not name why it happened: %+v", app)
}
}
func TestRestartOnFiresOnlyForResourcesThatChanged(t *testing.T) {
// restart-on must not mean "always restart": it names resources, and only a resource that
// changed this pass is a reason. This is the guard shared by containers and services
// (novox/hq 04-ISSUES/009), so a container that reflects an unchanged file is left running.
changed := map[string]bool{"other": true}
if got := restartedBy([]string{"config"}, changed); len(got) != 0 {
t.Errorf("an unchanged resource was treated as a reason to restart: %v", got)
}
changed["config"] = true
if got := restartedBy([]string{"config", "missing"}, changed); len(got) != 1 || got[0] != "config" {
t.Errorf("restart-on did not name exactly the changed resource: %v", got)
}
}
// --- boot state (novox/hq: a unit started but not enabled stops being true at the next reboot) ---
// systemctlStub answers `show` and `is-enabled` the way systemd does, and records the verbs it