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.