From 76dc93c421bf9738a130dd3b87dd7b49366e9878 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 00:46:39 +0000 Subject: [PATCH] Hold a library's containers through the write verb, as a full apply does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 11959 of mesh-host#111: a write through the verb recreated a container that reads the filled file over a library whose disk was missing (ADR 0328 §9). Also refuses a filled path two resources declare, with a test. Issue 668. Co-Authored-By: Claude Opus 5.5 --- internal/apply/filled.go | 3 ++ internal/apply/filled_library_test.go | 57 +++++++++++++++++++++++++++ internal/apply/filled_test.go | 21 ++++++++++ internal/apply/library.go | 18 ++++++--- 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 internal/apply/filled_library_test.go diff --git a/internal/apply/filled.go b/internal/apply/filled.go index a0d750f..f3f4800 100644 --- a/internal/apply/filled.go +++ b/internal/apply/filled.go @@ -72,6 +72,9 @@ func WriteFilled( in.refused = refusedPlaces(d.Resources) in.agentHomes = rootNeverHomes(d) in.dirsBefore = map[string]bool{} + // A container that reads the file and mounts a library whose disk or mount is not there is held, as a full apply + // holds it: not created over the missing disk (novox/hq ADR 0328 §9). + in.held = heldLibraries(ctx, d.Libraries) for _, r := range d.Resources { in.declares[r.Identity()] = declaredDigest(r) } diff --git a/internal/apply/filled_library_test.go b/internal/apply/filled_library_test.go new file mode 100644 index 0000000..a378c93 --- /dev/null +++ b/internal/apply/filled_library_test.go @@ -0,0 +1,57 @@ +//go:build linux + +package apply + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// A container that reads a filled file and mounts a library whose disk is not there is held by the write verb as a +// full apply holds it (novox/hq ADR 0328 §9, issue 668): never created or recreated over the missing disk. +func TestAWriteDoesNotRecreateAContainerHeldForItsLibrary(t *testing.T) { + // No folder is given the group: the library's root is absent, and nothing is made. + gid := 61000 + libraries(t, nil, nil) + base := t.TempDir() + root := filepath.Join(base, "gone", "media") + env := filepath.Join(base, "radarr.env") + raw := fmt.Sprintf(`{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":[%q], + "groups":[{"name":"mesh-media","gid":%d}],"libraries":[%s],"resources":[ + {"id":"radarr.env","type":"file","path":%q,"content":"","mode":"0600","owner-at-runtime":"owned"}, + {"id":"radarr.app","type":"container","name":"radarr","image":%q,"env-file":[%q],"volumes":[%q]}]}`, + env, gid, libJSON("media", root, gid, true, false, "movies"), env, pinned, env, + filepath.Join(root, "movies")+":/movies") + d, err := declaration.Parse([]byte(raw)) + if err != nil { + t.Fatal(err) + } + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + ran = append(ran, name+" "+strings.Join(args, " ")) + return "", nil + } + var state store.State + for _, content := range []string{"PORT=1\n", "PORT=2\n"} { + var report Report + report, state, err = WriteFilled(context.Background(), archHost(t), d, state, env, []byte(content), run, + nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(report, "radarr.app"); o.Action != "waiting" { + t.Errorf("after writing %q the container is %+v, not held for its library", content, o) + } + } + for _, line := range ran { + if strings.HasPrefix(line, "docker run") || strings.HasPrefix(line, "docker rm") { + t.Errorf("the container was created or removed over a missing disk: %v", ran) + } + } +} diff --git a/internal/apply/filled_test.go b/internal/apply/filled_test.go index b2e9003..6166d31 100644 --- a/internal/apply/filled_test.go +++ b/internal/apply/filled_test.go @@ -245,3 +245,24 @@ func count(lines []string, prefix string) int { } return n } + +// Two resources declaring one filled path — two modules' blocks in one file — are refused: the request carries the +// content only, so it cannot say whose block it is, and nothing is written. +func TestAFilledPathTwoResourcesDeclareIsRefused(t *testing.T) { + path := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(path, []byte(workstationHosts), 0o644); err != nil { + t.Fatal(err) + } + d := parseTrusted(t, fmt.Sprintf(`{"declaration":1,"filled_files":[%q],"resources":[ + {"id":"a.hosts","type":"file","path":%q,"into":"block","content":"10.42.0.1 ace\n","owner-at-runtime":"managed"}, + {"id":"b.hosts","type":"file","path":%q,"into":"block","content":"10.42.0.2 g14\n","owner-at-runtime":"managed"} + ]}`, path, path, path)) + _, state, err := WriteFilled(context.Background(), archHost(t), d, store.State{}, path, []byte("6.6.6.6 evil\n"), + noServices, nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "2 resources declare") { + t.Fatalf("a path two resources declare was not refused: %v", err) + } + if got := readText(t, path); got != workstationHosts || len(state.Resources) != 0 { + t.Errorf("something was written or recorded: %q %+v", got, state.Resources) + } +} diff --git a/internal/apply/library.go b/internal/apply/library.go index a198813..36a53b3 100644 --- a/internal/apply/library.go +++ b/internal/apply/library.go @@ -318,6 +318,17 @@ func heldRoots(verdicts map[string]libraryVerdict, libs []declaration.Library) m return out } +// heldLibraries is the root of every library whose disk or mount is not there, with its name, judged now and read +// only: what a full apply holds its containers for (ADR 0328 §9), for one that judges without applying libraries. +func heldLibraries(ctx context.Context, libs []declaration.Library) map[string]string { + verdicts := map[string]libraryVerdict{} + for _, l := range libs { + rec, _ := libraryRecord(l.Library) + verdicts[l.Library] = assessLibrary(ctx, l, rec) + } + return heldRoots(verdicts, libs) +} + // mountsHeld is the library a container waits for: one whose root it mounts, or mounts below, is held. func mountsHeld(c *declaration.Container, held map[string]string) string { for _, v := range c.Volumes { @@ -341,12 +352,7 @@ func WaitingOnLibrary(d *declaration.Declaration) map[string]bool { if d == nil || len(d.Libraries) == 0 { return out } - verdicts := map[string]libraryVerdict{} - for _, l := range d.Libraries { - rec, _ := libraryRecord(l.Library) - verdicts[l.Library] = assessLibrary(context.Background(), l, rec) - } - held := heldRoots(verdicts, d.Libraries) + held := heldLibraries(context.Background(), d.Libraries) for _, r := range d.Resources { if c, ok := r.(*declaration.Container); ok && mountsHeld(c, held) != "" { out[c.ID] = true