diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 873e484..1f20090 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -322,7 +322,14 @@ func ApplyKeeping( if o, isOpening := resource.(*declaration.Opening); isOpening { outcome, err = applyOpening(ctx, o, run, fw) } else { - outcome, err = applyOne(ctx, sys, resource, run, changed, declares, was, unseal) + // A file this host has no record of, under any id, is the machine's until the mesh + // writes over it — on any node, adopted or not: its original is kept first. + var keepFound Keep + if f, isFile := resource.(*declaration.File); isFile && was.ID == "" && + !known.Recorded(string(declaration.TypeFile), f.Path) { + keepFound = keep + } + outcome, err = applyOne(ctx, sys, resource, run, changed, declares, was, unseal, keepFound) } if err != nil { failed := &Error{Resource: resource.Identity(), Err: err, Done: report} @@ -423,12 +430,12 @@ type Unseal func(sealed string) ([]byte, error) func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner, changed map[string]bool, declares map[string]string, previous store.Applied, - unseal Unseal) (Outcome, error) { + unseal Unseal, keepFound Keep) (Outcome, error) { switch res := r.(type) { case *declaration.Directory: return applyDirectory(res) case *declaration.File: - return applyFile(res, previous, unseal) + return applyFile(res, previous, unseal, keepFound) case *declaration.Service: return applyService(ctx, sys, res, run, changed) case *declaration.Package: @@ -573,7 +580,9 @@ func applyAccess(r *declaration.Access) (Outcome, error) { return out, nil } -func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outcome, error) { +// keepFound, when not nil, is where the original of a file this host has no record of is kept +// before it is written over (novox/hq ADR 0100): once, never overwritten, and named in the outcome. +func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepFound Keep) (Outcome, error) { if r.Into != "" { return applyInto(r, previous) } @@ -670,7 +679,15 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc drifted := existed && previous.Wrote != "" && digestOf(string(existing)) != previous.Wrote modeSame := existed && beforeMode == mode.Perm() + kept := "" if !contentSame { + if existed && keepFound != nil { + // Before anything is written: a keep that fails stops the write, since the + // original could not be had back otherwise. + if kept, err = keepFound(r.Path, existing, beforeMode); err != nil { + return out, fmt.Errorf("keeping the original of %s before writing over it: %w", r.Path, err) + } + } if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil { return out, err } @@ -734,6 +751,12 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc default: out.Action = "unchanged" } + if kept != "" { + if out.Detail != "" { + out.Detail += "; " + } + out.Detail += "the file found here, which the mesh had no record of, was kept at " + kept + } return out, nil } diff --git a/internal/apply/hold_test.go b/internal/apply/hold_test.go index a6c65b0..5c7b03a 100644 --- a/internal/apply/hold_test.go +++ b/internal/apply/hold_test.go @@ -472,8 +472,40 @@ func TestAConvergedNodeStillReplacesWhatItFinds(t *testing.T) { 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") + // What it writes over that it has no record of, it keeps first — on any node. + o := outcomeOf(report, "hello-web.page") + kept := o.Detail[strings.Index(o.Detail, "kept at ")+len("kept at "):] + if got, err := os.ReadFile(kept); err != nil || string(got) != "the predecessor's page\n" { + t.Errorf("the file written over was not kept, or not named: %q (%s) %v", got, o.Detail, err) + } +} + +func TestAFileWrittenOverIsKeptOnceAndOnlyWhenTheHostHasNoRecordOfIt(t *testing.T) { + dir := t.TempDir() + conf := filepath.Join(dir, "nftables.conf") + _ = os.WriteFile(conf, []byte("# the distribution's own\n"), 0o644) + decl := func(content string) *declaration.Declaration { + return parse(t, `{"declaration":1,"resources":[{"id":"nftables.config","type":"file","path":"`+conf+ + `","content":"`+content+`"}]}`) + } + m := &machine{containers: map[string]*fakeContainer{}} + report, state := applyAdopted(t, decl("table inet mesh {}\\n"), store.State{}, m, dir) + o := outcomeOf(report, "nftables.config") + if !strings.Contains(o.Detail, "had no record of, was kept at ") { + t.Fatalf("writing over an unrecorded file did not keep it: %+v", o) + } + kept := o.Detail[strings.Index(o.Detail, "kept at ")+len("kept at "):] + if got, _ := os.ReadFile(kept); string(got) != "# the distribution's own\n" { + t.Errorf("the kept original is %q", got) + } + + // Now the mesh's: a later change keeps nothing more, and the first original stays. + report, _ = applyAdopted(t, decl("table inet mesh { }\\n"), state, m, dir) + if o := outcomeOf(report, "nftables.config"); o.Action != "updated" || strings.Contains(o.Detail, "kept at") { + t.Errorf("a file the mesh wrote was kept again: %+v", o) + } + if got, _ := os.ReadFile(kept); string(got) != "# the distribution's own\n" { + t.Errorf("the first original was overwritten: %q", got) } }