Files
mesh-host/internal/apply/reads_test.go
T
jschoubben c0d94e04f3 Recreate a container when the content of a file it reads at creation changes
The host decided whether a container was still the one declared by a digest
of its declaration, and the declaration names an env-file's path and a
mount's path — never what is in them. So when the store was given a new
port, the host rewrote the forge's and the analytics service's environment
files, correctly, and left both containers running with the old port in
their environment: a container reads its env-file when it is CREATED, and
`docker restart` hands it the same environment again. Both looked healthy
until they answered 502.

What a running container takes in at creation is now part of its spec, by
content: every env-file, a file bind-mounted into it, and every file this
host wrote at or under a directory bind-mounted into it — the secrets,
bindings and configs under a module's state directories. The digest is the
one the store already records for a file the host wrote (`wrote`), read
from the state as it stands when the container is reached, so a file
rewritten earlier in the same apply is already the new one; a file the host
has no record of — an env-file a predecessor left, the superuser secret
genesis writes before any declaration names it — is read from disk, which
is what keeps adopting a running store in place a reconcile and not a
recreate.

Deliberately not part of it: what else is in a bind-mounted directory,
which is the service's own data and changes while it runs; a named volume;
a seed created once, which digests as the seed the host wrote and not as
what has grown in it; and a step — a run-once or scheduled container reads
its files when it runs and runs fresh each time. On an adopted node a held
container is held before any of this is looked at.

The host records what each container was created reading, per file, so
the recreate can say which file changed — "recreated: <file> changed" in
the report and, now with its detail, in the log. A container made before
this record existed is recreated once and says so.

novox/hq 04-ISSUES/103
2026-09-23 23:12:52 +02:00

222 lines
9.6 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"
)
// Defends novox/hq 04-ISSUES/103: a container is recreated when the CONTENT of a file it reads at
// creation changes, not only when its path does. A container takes its env-file and its mounted
// files in once, when it is created; `docker restart` hands it the same environment again, so
// only a recreate carries a rewritten file into the process.
//
// The runtime here is the `machine` fake: it keeps the spec label the host gave a container and
// hands it back on inspect, so the comparison under test is the one the host really makes,
// against what it really wrote — not against a spec a test imagined.
func applyCarried(t *testing.T, d *declaration.Declaration, known store.State, m *machine,
log func(string)) (Report, store.State) {
t.Helper()
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, m.run, log, nil)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
return report, state
}
func TestAContainerIsRecreatedWhenItsEnvFileChanged(t *testing.T) {
// The night of the issue: the store was given a new port, the host rewrote the forge's
// environment file with it — and left the forge running with the old one.
dir := t.TempDir()
env := filepath.Join(dir, "forge.env")
declare := func(port string) *declaration.Declaration {
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n","mode":"0600"},
{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}
]}`)
}
m := &machine{containers: map[string]*fakeContainer{}}
var logged []string
log := func(line string) { logged = append(logged, line) }
report, state := applyCarried(t, declare("5432"), store.State{}, m, log)
if o := outcomeOf(report, "forge.server"); o.Action != "created" {
t.Fatalf("the container was not created: %+v", report.Outcomes)
}
// The store moved. The file is rewritten in this apply, before the container is reached, and
// the container must follow it in the same pass.
m.asked, logged = nil, nil
report, state = applyCarried(t, declare("5433"), state, m, log)
if !m.removed("forge") || !m.did("docker run") {
t.Fatalf("the container kept running with the old environment after its env-file changed: %v", m.asked)
}
o := outcomeOf(report, "forge.server")
if o.Action != "updated" || o.Detail != "recreated: "+env+" changed" {
t.Errorf("the recreate did not say which file changed: %+v", o)
}
var said bool
for _, line := range logged {
if strings.Contains(line, "updated forge.server") && strings.Contains(line, "recreated: "+env+" changed") {
said = true
}
}
if !said {
t.Errorf("the log did not say which file made the container recreate: %q", logged)
}
// And with nothing moved, it is left alone: content is part of the identity, not a tripwire.
m.asked = nil
report, _ = applyCarried(t, declare("5433"), state, m, log)
if m.did("docker rm") || m.did("docker run") || report.Changed() {
t.Errorf("a container whose env-file did not change was recreated: %v %+v", m.asked, report.Outcomes)
}
}
func TestAnEnvFileTheHostDidNotWriteIsStillReadForWhatItHolds(t *testing.T) {
// The host has no record of this file — a predecessor left it, or something else on the
// machine maintains it — and the container still reads it once. Its content is read from the
// disk, so a change is a recreate exactly as for a file the host wrote.
dir := t.TempDir()
env := filepath.Join(dir, "app.env")
if err := os.WriteFile(env, []byte("TOKEN=old\n"), 0o600); err != nil {
t.Fatal(err)
}
d := parseTrusted(t, `{"declaration":1,"resources":[
{"id":"app.server","type":"container","name":"app","image":"`+pinned+`","env-file":["`+env+`"]}
]}`)
m := &machine{containers: map[string]*fakeContainer{}}
_, state := applyCarried(t, d, store.State{}, m, nil)
if err := os.WriteFile(env, []byte("TOKEN=new\n"), 0o600); err != nil {
t.Fatal(err)
}
m.asked = nil
report, _ := applyCarried(t, d, state, m, nil)
if !m.removed("app") || !m.did("docker run") {
t.Fatalf("a container reading an env-file the host did not write was not recreated when it changed: %v", m.asked)
}
if o := outcomeOf(report, "app.server"); o.Detail != "recreated: "+env+" changed" {
t.Errorf("the recreate did not name the file: %+v", o)
}
}
func TestAContainerIsRecreatedWhenAMountedSecretChanged(t *testing.T) {
// A rotated credential has the same shape as a moved port: the host writes the file the
// container mounts, and the process holds the value it was created with.
dir := t.TempDir()
secret := filepath.Join(dir, "db.secret")
declare := func(value string) *declaration.Declaration {
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"app.secret","type":"file","path":"`+secret+`","content":"`+value+`","mode":"0600"},
{"id":"app.server","type":"container","name":"app","image":"`+pinned+`",
"volumes":["`+secret+`:/run/secrets/db:ro"]}
]}`)
}
m := &machine{containers: map[string]*fakeContainer{}}
_, state := applyCarried(t, declare("hunter2"), store.State{}, m, nil)
m.asked = nil
report, _ := applyCarried(t, declare("correct-horse-battery-staple"), state, m, nil)
if !m.removed("app") || !m.did("docker run") {
t.Fatalf("the container kept the secret it was created with after the mounted file changed: %v", m.asked)
}
if o := outcomeOf(report, "app.server"); o.Action != "updated" || o.Detail != "recreated: "+secret+" changed" {
t.Errorf("the recreate did not say which file changed: %+v", o)
}
}
func TestWhatAServiceWritesInAMountedDirectoryIsNotPartOfWhatItIs(t *testing.T) {
// A bind-mounted directory is the service's data: it changes while the service runs, and
// recreating for it would restart a database for every row it wrote. What the HOST wrote
// under that directory — a config it rendered there — is another matter: the service read
// that once, at start.
dir := t.TempDir()
state := filepath.Join(dir, "state")
config := filepath.Join(state, "config.toml")
declare := func(level string) *declaration.Declaration {
return parseTrusted(t, `{"declaration":1,"resources":[
{"id":"app.state","type":"directory","path":"`+state+`"},
{"id":"app.config","type":"file","path":"`+config+`","content":"level = \"`+level+`\"\n"},
{"id":"app.server","type":"container","name":"app","image":"`+pinned+`",
"volumes":["`+state+`:/var/lib/app"]}
]}`)
}
m := &machine{containers: map[string]*fakeContainer{}}
_, known := applyCarried(t, declare("info"), store.State{}, m, nil)
// The service grows its data in the directory it was given.
if err := os.WriteFile(filepath.Join(state, "app.db"), []byte("rows"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(state, "cache"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(state, "cache", "index"), []byte("entries"), 0o600); err != nil {
t.Fatal(err)
}
m.asked = nil
report, known := applyCarried(t, declare("info"), known, m, nil)
if m.did("docker rm") || m.did("docker run") || report.Changed() {
t.Errorf("a container was recreated for data its service wrote in a mounted directory: %v %+v",
m.asked, report.Outcomes)
}
// The host's own config under the same directory changes: that, the service read at start.
m.asked = nil
report, _ = applyCarried(t, declare("debug"), known, m, nil)
if !m.removed("app") || !m.did("docker run") {
t.Fatalf("a config the host wrote under a mounted directory changed and the container was not recreated: %v", m.asked)
}
if o := outcomeOf(report, "app.server"); o.Detail != "recreated: "+config+" changed" {
t.Errorf("the recreate did not name the config: %+v", o)
}
}
func TestAHeldContainerIsNotRecreatedByAChangedHeldFile(t *testing.T) {
// On an adopted node the predecessor's container and the file it reads are both held as
// found (novox/hq ADR 0100). The predecessor rewriting its own file is reported on the file
// — and is nothing to recreate the container for: it is not the host's to recreate.
dir := t.TempDir()
env := filepath.Join(dir, "hello.env")
if err := os.WriteFile(env, []byte("PORT=5432\n"), 0o600); err != nil {
t.Fatal(err)
}
m := &machine{containers: map[string]*fakeContainer{
"hello-web": {id: "predecessor-id", running: true},
}}
d := adopted(t, untaken("hello-web.env", "hello-web.server"),
`{"id":"hello-web.env","type":"file","path":"`+env+`","content":"PORT=5433\n","mode":"0600"},
{"id":"hello-web.server","type":"container","name":"hello-web","image":"`+pinned+`","env-file":["`+env+`"]}`)
report, state := applyAdopted(t, d, store.State{}, m, dir)
if outcomeOf(report, "hello-web.env").Action != "held" || outcomeOf(report, "hello-web.server").Action != "held" {
t.Fatalf("the predecessor's file and container were not held: %+v", report.Outcomes)
}
if err := os.WriteFile(env, []byte("PORT=5434\n"), 0o600); err != nil {
t.Fatal(err)
}
m.asked = nil
report, state = applyAdopted(t, d, state, m, dir)
for _, a := range m.asked {
if strings.HasPrefix(a, "docker run") || strings.HasPrefix(a, "docker rm") {
t.Fatalf("a held container was acted on because a held file changed: %s", a)
}
}
if o := outcomeOf(report, "hello-web.server"); o.Action != "held" {
t.Errorf("the container is no longer held: %+v", o)
}
if h, _ := state.HeldAt("hello-web.env"); h.Changed != "rewritten" {
t.Errorf("the predecessor's rewrite was not reported on the file: %+v", h)
}
if h, _ := state.HeldAt("hello-web.server"); h.Changed != "" {
t.Errorf("a file change was charged to the container: %+v", h)
}
}