From 827ce481f2cf3a451a05e3c895011bc755b31ee7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 29 Aug 2026 22:55:26 +0200 Subject: [PATCH] Somebody editing a managed file is now visible instead of mysterious Asked how the mesh would know if somebody edited their hosts file. It would not. The file was rewritten within five minutes and the outcome said "updated" -- which is exactly what the mesh changing its own mind looks like. So the change vanished, nothing anywhere said why, and the obvious thing to do is edit it again. The host now records a digest of what it wrote, which is enough to tell the two apart on the next pass: the file matches the declaration unchanged it matches what was last written updated -- the mesh changed its mind it matches neither corrected -- somebody changed it here The machine is put back either way, because holding it to what it was told is the point. What changes is that it says so. A digest rather than the content: the store is read on every reconcile and sits beside the state on disk, and keeping every managed file twice would make it grow with the size of the machine rather than with the number of resources. --- internal/apply/apply.go | 45 ++++++++++++++++-- internal/apply/apply_test.go | 92 ++++++++++++++++++++++++++++++++++++ internal/store/store.go | 10 ++++ 3 files changed, 142 insertions(+), 5 deletions(-) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 45e8a9e..46a19a2 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -13,6 +13,7 @@ package apply import ( "context" "crypto/sha256" + "encoding/hex" "errors" "fmt" "os" @@ -37,8 +38,17 @@ type Outcome struct { ID string `json:"id"` Type string `json:"type"` Target string `json:"target"` - Action string `json:"action"` // created · updated · unchanged · removed + // Action is created · updated · unchanged · corrected · removed. + // + // "corrected" is its own answer and not a kind of "updated": it means the machine had drifted + // from what this host last wrote, so somebody changed it by hand. The mesh converging is + // right either way; being unable to say which happened is not. + Action string `json:"action"` Detail string `json:"detail,omitempty"` + + // wrote is a digest of what this apply put there, kept so the next one can tell a machine + // that drifted from one the mesh changed its mind about. Not reported: it is bookkeeping. + wrote string } // Report is what an apply did, in the order it did it. @@ -118,7 +128,8 @@ func Apply( changed := map[string]bool{} for _, resource := range d.Resources { - outcome, err := applyOne(ctx, sys, resource, run, changed) + was, _ := known.Find(resource.Identity()) + outcome, err := applyOne(ctx, sys, resource, run, changed, was) if err != nil { return report, known, &Error{Resource: resource.Identity(), Err: err, Done: report} } @@ -128,6 +139,7 @@ func Apply( Origin: origin, ID: resource.Identity(), Type: string(resource.Kind()), Target: outcome.Target, AppliedAt: time.Now().UTC(), + Wrote: outcome.wrote, }) report.Outcomes = append(report.Outcomes, outcome) if outcome.Action != "unchanged" { @@ -139,12 +151,12 @@ func Apply( } func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner, - changed map[string]bool) (Outcome, error) { + changed map[string]bool, previous store.Applied) (Outcome, error) { switch res := r.(type) { case *declaration.Directory: return applyDirectory(res) case *declaration.File: - return applyFile(res) + return applyFile(res, previous) case *declaration.Service: return applyService(ctx, sys, res, run, changed) case *declaration.Package: @@ -227,8 +239,9 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) { return out, nil } -func applyFile(r *declaration.File) (Outcome, error) { +func applyFile(r *declaration.File, previous store.Applied) (Outcome, error) { out := begin(r) + out.wrote = digestOf(r.Content) mode, err := modeOf(r.Mode, 0o644) if err != nil { return out, err @@ -248,6 +261,11 @@ func applyFile(r *declaration.File) (Outcome, error) { } contentSame := existed && string(existing) == r.Content + + // Whether the machine still holds what this host last put there. When it does not, and the + // declaration has not changed either, somebody edited it — and saying so is the whole + // difference between a change that vanishes mysteriously and one that is reported. + drifted := existed && previous.Wrote != "" && digestOf(string(existing)) != previous.Wrote modeSame := existed && beforeMode == mode.Perm() if !contentSame { @@ -282,6 +300,13 @@ func applyFile(r *declaration.File) (Outcome, error) { switch { case !existed: out.Action = "created" + case drifted: + // Somebody changed this on the machine. The mesh puts it back either way — that is what + // holding a machine to what it was told means — but a change that vanishes with nothing + // said is how a person ends up editing the same file every five minutes, believing the + // machine is broken. + out.Action = "corrected" + out.Detail = "it had been changed on the machine since this host last wrote it" case !contentSame && !modeSame: out.Action = "updated" out.Detail = "content and mode" @@ -769,3 +794,13 @@ func containerRuntime(ctx context.Context, run Runner) (string, error) { return "", fmt.Errorf( "no container runtime answers on this machine (tried %s)", strings.Join(tried, ", ")) } + +// digestOf is how this host recognises what it wrote. +// +// A digest rather than the content: the store is read on every reconcile and sits beside the +// state on disk, and keeping every managed file twice would make it grow with the machine rather +// than with the number of resources. +func digestOf(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 0dfbb4b..c2c0d03 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -1059,3 +1059,95 @@ func recordingServices(commands *[]string) Runner { return "", nil } } + +func TestAFileChangedOnTheMachineIsCorrectedAndSaidSo(t *testing.T) { + // The question this answers: how would anybody know somebody edited a managed file? Before + // this they would not. It was rewritten within five minutes and reported as "updated", + // which is what the mesh changing its mind looks like — so the person's change vanished and + // nothing anywhere said why. They edit it again, and again. + dir := t.TempDir() + path := filepath.Join(dir, "thing.conf") + d := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"conf","type":"file","path":%q,"content":"from the mesh\n","mode":"0644"} + ]}`, path)) + + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil) + if err != nil { + t.Fatal(err) + } + + // Somebody edits it. + if err := os.WriteFile(path, []byte("edited by hand\n"), 0o644); err != nil { + t.Fatal(err) + } + + report, state, err := Apply(context.Background(), archHost(t), d, state, + store.OriginCarried, noServices, nil) + if err != nil { + t.Fatal(err) + } + if got := report.Outcomes[0].Action; got != "corrected" { + t.Errorf("a hand edit was reported as %q; the mesh cannot tell it from changing its own "+ + "mind, and neither can anybody reading this", got) + } + + // And it is put back, because holding the machine to what it was told is the point. + back, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(back) != "from the mesh\n" { + t.Errorf("the file was left as %q", back) + } +} + +func TestTheMeshChangingItsMindIsNotDrift(t *testing.T) { + // The other half. A new declaration is an ordinary update and must not read as somebody + // having meddled, or every real change would look like an incident. + dir := t.TempDir() + path := filepath.Join(dir, "thing.conf") + first := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"conf","type":"file","path":%q,"content":"one\n","mode":"0644"} + ]}`, path)) + second := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"conf","type":"file","path":%q,"content":"two\n","mode":"0644"} + ]}`, path)) + + _, state, err := Apply(context.Background(), archHost(t), first, store.State{}, + store.OriginCarried, noServices, nil) + if err != nil { + t.Fatal(err) + } + report, _, err := Apply(context.Background(), archHost(t), second, state, + store.OriginCarried, noServices, nil) + if err != nil { + t.Fatal(err) + } + if got := report.Outcomes[0].Action; got != "updated" { + t.Errorf("the mesh changing what it wants was reported as %q", got) + } +} + +func TestAnUntouchedFileIsStillUnchanged(t *testing.T) { + // And nothing about this makes a steady machine look busy. + dir := t.TempDir() + path := filepath.Join(dir, "thing.conf") + d := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"conf","type":"file","path":%q,"content":"steady\n","mode":"0644"} + ]}`, path)) + + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginCarried, noServices, nil) + if err != nil { + t.Fatal(err) + } + report, _, err := Apply(context.Background(), archHost(t), d, state, + store.OriginCarried, noServices, nil) + if err != nil { + t.Fatal(err) + } + if got := report.Outcomes[0].Action; got != "unchanged" { + t.Errorf("an untouched file was reported as %q", got) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 060d3f3..6b87884 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -47,6 +47,16 @@ type Applied struct { // re-reading a declaration that may no longer exist. Target string `json:"target"` AppliedAt time.Time `json:"applied_at"` + + // Wrote is a digest of what this host last put there, for resources where that is a + // meaningful question. + // + // Without it, a file that does not match the declaration has two possible explanations and + // the host cannot tell them apart: the mesh changed what it wants, or somebody edited the + // machine. Both end with the file being rewritten, so the outcome is identical — and a + // person who edits a managed file watches their change vanish every few minutes with nothing + // anywhere saying why. + Wrote string `json:"wrote,omitempty"` } // State is the whole of what a node knows about what it has done.