From 4a095df2f917cf1aafff42f08ce7e6499b9b49ee Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 18:14:37 +0200 Subject: [PATCH] Let go of a hold whose resource is no longer declared, touching nothing on disk (hq ADR 0100) --- internal/apply/apply.go | 18 +++++ internal/apply/hold_test.go | 137 +++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 650c4fe..f9e8d96 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -202,6 +202,24 @@ func ApplyKeeping( } } + // **A hold whose resource is no longer declared is let go, and nothing on disk is touched.** + // What was found stays as it was; only the host's note that it holds it for a module goes, so + // the node stops reporting a hold for a module no longer assigned. Should the module come back, + // what is there is present with no record and is found, and held, again — its first kept + // original is never overwritten (novox/hq ADR 0100). Only a declaration from the mesh says + // what is assigned: a carried bundle's silence is not an unassignment. + if origin == store.OriginDeclared { + for _, h := range append([]store.Held{}, known.Held...) { + if declared[h.ID] { + continue + } + known.Release(h.ID) + report.Outcomes = append(report.Outcomes, Outcome{ID: h.ID, Type: h.Kind, Target: h.Target, + Action: "forgotten", Detail: "no longer declared; left as found"}) + log(fmt.Sprintf(" forgotten %s (%s): no longer declared; left as found", h.ID, h.Target)) + } + } + // What moved in this apply, so a service that must reflect a file can be told the file // moved. Only within one apply: a change from an earlier one has already been reflected, and // restarting for it every time would make a steady machine restart its services for ever. diff --git a/internal/apply/hold_test.go b/internal/apply/hold_test.go index 9699edd..98813b9 100644 --- a/internal/apply/hold_test.go +++ b/internal/apply/hold_test.go @@ -332,8 +332,141 @@ func TestAHeldFileIsNeverRemovedWhenItsModuleIsUnassigned(t *testing.T) { if m.removed("hello-web") { t.Fatal("a held container was removed when its module left") } - if _, still := state.HeldAt("hello-web.page"); !still { - t.Error("the hold was forgotten, so a return of the module would read the file as the mesh's") + if _, still := state.HeldAt("hello-web.page"); still { + t.Error("a hold outlived its resource leaving the declaration, so the node reports it for ever") + } + if _, recorded := state.Find("hello-web.page"); recorded { + t.Error("a file let go was recorded as the mesh's") + } +} + +func TestAHoldNoLongerDeclaredIsLetGoAndFoundAgainIfItsModuleReturns(t *testing.T) { + dir, page, m := predecessor(t) + _, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir) + first, _ := state.HeldAt("hello-web.page") + + report, state := applyAdopted(t, adopted(t, `{"taken":[]}`, withConf(dir)), state, m, dir) + if o := outcomeOf(report, "hello-web.page"); o.Action != "forgotten" || o.Detail != "no longer declared; left as found" { + t.Errorf("letting a hold go was not reported: %+v", o) + } + if len(state.Held) != 0 { + t.Errorf("holds outlived their resources: %+v", state.Held) + } + + // The module comes back: what is there is found again, and the original first kept stays. + if err := os.WriteFile(page, []byte("the predecessor wrote again\n"), 0o640); err != nil { + t.Fatal(err) + } + report, state = applyAdopted(t, adopted(t, untakenWeb, webResources(page)), state, m, dir) + if o := outcomeOf(report, "hello-web.page"); o.Action != "held" { + t.Fatalf("a returning module's found file was not held again: %+v", o) + } + again, _ := state.HeldAt("hello-web.page") + if kept, _ := os.ReadFile(again.Kept); again.Kept != first.Kept || string(kept) != "the predecessor's page\n" { + t.Errorf("the first kept original was lost: %s %q", again.Kept, kept) + } + if got, _ := os.ReadFile(page); string(got) != "the predecessor wrote again\n" { + t.Errorf("the found file was touched: %q", got) + } +} + +func TestACarriedApplyLetsNoHoldGo(t *testing.T) { + dir, page, m := predecessor(t) + _, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir) + carried := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`) + _, state, err := ApplyKeeping(context.Background(), archHost(t), carried, state, store.OriginCarried, + m.run, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(state.Held) != 2 { + t.Errorf("a carried apply let holds go: %+v", state.Held) + } +} + +func TestAHeldFileRewrittenIsReportedAndNotReverted(t *testing.T) { + dir, page, m := predecessor(t) + d := adopted(t, untakenWeb, webResources(page)) + _, state := applyAdopted(t, d, store.State{}, m, dir) + + if err := os.WriteFile(page, []byte("the predecessor wrote again\n"), 0o640); err != nil { + t.Fatal(err) + } + report, state := applyAdopted(t, d, state, m, dir) + if got, _ := os.ReadFile(page); string(got) != "the predecessor wrote again\n" { + t.Fatalf("a held file was reverted: %q", got) + } + if h, _ := state.HeldAt("hello-web.page"); h.Changed != "rewritten" || h.ChangedAt.IsZero() { + t.Errorf("a rewrite was not recorded: %+v", h) + } + if o := outcomeOf(report, "hello-web.page"); !strings.Contains(o.Detail, "rewritten") { + t.Errorf("a rewrite was not reported: %+v", o) + } + h, _ := state.HeldAt("hello-web.page") + if kept, _ := os.ReadFile(h.Kept); string(kept) != "the predecessor's page\n" { + t.Errorf("the kept original was overwritten by a later write: %q", kept) + } +} + +func TestAHeldContainerStoppedOrReplacedIsReportedAndNotRestarted(t *testing.T) { + for _, c := range []struct { + change func(*machine) + want string + }{ + {func(m *machine) { m.containers["hello-web"].running = false }, "stopped"}, + {func(m *machine) { m.containers["hello-web"].id = "another" }, "replaced"}, + {func(m *machine) { delete(m.containers, "hello-web") }, "gone"}, + } { + dir, page, m := predecessor(t) + d := adopted(t, untakenWeb, webResources(page)) + _, state := applyAdopted(t, d, store.State{}, m, dir) + c.change(m) + m.asked = nil + _, state = applyAdopted(t, d, state, m, dir) + if h, _ := state.HeldAt("hello-web.server"); h.Changed != c.want { + t.Errorf("%s: recorded as %q", c.want, h.Changed) + } + for _, a := range m.asked { + if strings.HasPrefix(a, "docker run") || strings.HasPrefix(a, "docker rm") || + strings.HasPrefix(a, "docker start") { + t.Errorf("%s: the held container was acted on: %s", c.want, a) + } + } + } +} + +func TestAHeldFileThatVanishesIsNotCreated(t *testing.T) { + dir, page, m := predecessor(t) + d := adopted(t, untakenWeb, webResources(page)) + _, state := applyAdopted(t, d, store.State{}, m, dir) + if err := os.Remove(page); err != nil { + t.Fatal(err) + } + _, state = applyAdopted(t, d, state, m, dir) + if _, err := os.Stat(page); !errors.Is(err, os.ErrNotExist) { + t.Fatal("a held file that vanished was created before its module was taken") + } + if h, _ := state.HeldAt("hello-web.page"); h.Changed != "gone" { + t.Errorf("a vanished held file was not reported gone: %+v", h) + } +} + +func TestAConvergedNodeStillReplacesWhatItFinds(t *testing.T) { + // No adoption, no holds: byte for byte what a converged node did before ADR 0100. + dir, page, m := predecessor(t) + d := parse(t, `{"declaration":1,"resources":[`+webResources(page)+`]}`) + report, state := applyAdopted(t, d, store.State{}, m, dir) + if got, _ := os.ReadFile(page); string(got) != "the mesh's page\n" { + t.Errorf("a converged node kept a found file: %q", got) + } + if !m.removed("hello-web") { + t.Error("a converged node kept a found container") + } + if len(state.Held) != 0 || outcomeOf(report, "hello-web.page").Action == "held" { + t.Errorf("a converged node held something: %+v", state.Held) + } + if _, err := os.Stat(filepath.Join(dir, "kept")); !errors.Is(err, os.ErrNotExist) { + t.Error("a converged node kept originals") } }