From 4840e21405a4a86f04b3047a036f7fe301a45274 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 23 Sep 2026 23:42:41 +0200 Subject: [PATCH] Say in the plan which file a container would be recreated for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan says what an apply would change from the declaration and the record, before the machine is touched. The apply now recreates a container when the content of a file it reads at creation changed, and the plan said "check" for every recorded container — true, but a preview that hides the one step somebody asked about. So a recorded container whose record of what it read differs from what this apply will hand it — a plain file declared here, by its declared content; otherwise what this host last wrote at that path — is planned as an update naming the file, the same comparison applyContainer makes. What the record cannot settle stays a check: a file neither declared nor recorded is read from the machine by the apply, not by the plan; and a container with no record of what it read was labelled before the host kept that record and is accepted as it is. novox/hq 04-ISSUES/103, 104 --- internal/apply/plan.go | 58 +++++++++++++++++++++++++++++++++++++ internal/apply/plan_test.go | 36 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/internal/apply/plan.go b/internal/apply/plan.go index ced96cc..07205bd 100644 --- a/internal/apply/plan.go +++ b/internal/apply/plan.go @@ -191,10 +191,68 @@ func planned(r declaration.Resource, d *declaration.Declaration, known store.Sta step.Verb, step.Why = "update", "the declaration changed since this host applied it" return step } + if c, ok := r.(*declaration.Container); ok { + if changed := readsChanged(c, d, known, was.Reads); len(changed) > 0 { + step.Verb = "update" + step.Why = "recreated: " + strings.Join(changed, ", ") + " changed since it was created" + return step + } + } step.Verb, step.Why = "check", "recorded here; corrected if this machine drifted from it" return step } +// readsChanged is which of the files a container was created reading the apply will hand it +// changed — the same comparison applyContainer makes (novox/hq 04-ISSUES/103), settled from the +// declaration and the record alone. +// +// A file's digest is what this apply will record for it: a plain file declared here, by its +// declared content; otherwise what this host last wrote there, under any id. A file neither +// declares nor records — an env-file a predecessor left — is read by the apply from the machine, +// which a plan does not do, so it stays a check. A container with no record of what it read was +// labelled before the host kept that record and is accepted as it is, so it is a check too. +func readsChanged(c *declaration.Container, d *declaration.Declaration, known store.State, + wasReading map[string]string) []string { + if len(wasReading) == 0 { + return nil + } + willWrite := map[string]string{} + for _, r := range d.Resources { + if f, ok := r.(*declaration.File); ok { + if want := wouldWrite(f); want != "" { + willWrite[f.Path] = want + } + } + } + // Only what it still reads: a file it was created reading and no longer names is a changed + // declaration, not a changed file. + stillReads := map[string]bool{} + for _, path := range c.EnvFile { + stillReads[path] = true + } + for _, v := range c.Volumes { + if src := mountSource(v); strings.HasPrefix(src, "/") { + stillReads[src] = true + } + } + var changed []string + for _, path := range sortedKeys(wasReading) { + if !stillReads[path] { + continue + } + now, settled := willWrite[path] + if !settled { + if f, recorded := known.At(string(declaration.TypeFile), path); recorded { + now, settled = f.Wrote, true + } + } + if settled && now != wasReading[path] { + changed = append(changed, path) + } + } + return changed +} + // wouldWrite is the digest a plain file would be recorded under, or empty where only the apply // can know: a sealed file, one with secrets in it, one written into, one carrying bytes. func wouldWrite(r declaration.Resource) string { diff --git a/internal/apply/plan_test.go b/internal/apply/plan_test.go index 768230a..4f8f7ce 100644 --- a/internal/apply/plan_test.go +++ b/internal/apply/plan_test.go @@ -225,3 +225,39 @@ func TestAResourceRunInAHeldContainerIsPlannedAsItIsApplied(t *testing.T) { t.Errorf("planned %q", got) } } + +func TestAPlanSaysAContainerIsRecreatedWhenAFileItReadsChanged(t *testing.T) { + // The apply recreates a container when the content of a file it reads at creation changed + // (novox/hq 04-ISSUES/103); the plan says so from the record alone — what the container was + // created reading, against what this apply will write. And a container recorded before the + // host kept that record is accepted, so it is a check, not an update. + dir := t.TempDir() + env := filepath.Join(dir, "forge.env") + declare := func(port string) *declaration.Declaration { + return trusted(t, `{"declaration":1,"resources":[ + {"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n"}, + {"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}]}`) + } + created := digestOf("DATABASE_PORT=5432\n") + known := store.State{} + known.Record(store.Applied{ID: "forge.env", Type: "file", Target: env, Wrote: created}) + known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge", + Reads: map[string]string{env: created}}) + + if got := verbs(Plan(declare("5432"), known, store.OriginCarried)); got != "check forge.env, check forge.server" { + t.Errorf("nothing changed and the plan says %q", got) + } + steps := Plan(declare("5433"), known, store.OriginCarried) + if got := verbs(steps); got != "update forge.env, update forge.server" { + t.Fatalf("the env-file changes and the plan says %q", got) + } + if !strings.Contains(steps[1].Why, env+" changed") { + t.Errorf("the plan does not say which file: %+v", steps[1]) + } + + // No record of what it read: labelled by an earlier host, accepted as it is. + known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge"}) + if got := verbs(Plan(declare("5433"), known, store.OriginCarried)); got != "update forge.env, check forge.server" { + t.Errorf("a container with no record of what it read is planned as %q", got) + } +}