Files
mesh-host/internal/apply/file_handover_test.go
T
jochen 762da9e05e Hand a whole file to its new owner instead of removing it first (hq ADR 0223)
The resolver file moves from resolv-conf to the uplink's holder in one apply.
Orphans go first, so the file was deleted or given back its pre-mesh original
until the new owner's turn came. Now the old record is forgotten once the new
one is recorded at the same path, and the kept original goes with it.
2026-10-05 23:35:40 +02:00

180 lines
7.5 KiB
Go

package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// novox/hq ADR 0223: the machine's resolver file moves from the module that wrote it to the uplink's
// holder, and the two records meet in one apply — the old undeclared, the new declared at the same
// path. Every orphan is removed before anything is applied, so without a handover the file was
// deleted (or given back the original the mesh once wrote over) and every resource applied before the
// new one ran on a machine without it. Handed over, the file is never absent, the record going is
// forgotten only once the new one is recorded, and what was kept of the machine's original goes with
// the file to its new owner.
const (
theOriginal = "nameserver 192.0.2.53\n"
firstOwners = "# the mesh, as the first module wrote it\nnameserver 10.42.0.1\n"
newOwners = "# the mesh, as the uplink writes it\nnameserver 10.42.0.1\nnameserver 10.42.0.3\n"
)
// heldBy is a declaration whose file at path is the module's, with a service declared ahead of it
// so the apply does something on the machine before the file's turn comes.
func heldBy(t *testing.T, path, module, content string) string {
t.Helper()
return fmt.Sprintf(`{"declaration":1,"resources":[
{"id":"%[1]s.service","type":"service","unit":"%[1]s.service","state":"running"},
{"id":"%[1]s.fact-resolvers","type":"file","path":%[2]q,"mode":"0644","content":%[3]q}
]}`, module, path, content)
}
// watching is a service manager that answers every unit running, and fails the test whenever the
// file is not there while it is asked something — the moment between an orphan's removal and the
// new owner's turn.
func watching(t *testing.T, path string) Runner {
t.Helper()
var commands []string
services := recordingServices(&commands)
return func(ctx context.Context, name string, args ...string) (string, error) {
if _, err := os.Stat(path); err != nil {
t.Errorf("the file was not there while %s %s ran: %v", name, strings.Join(args, " "), err)
}
return services(ctx, name, args...)
}
}
func TestAWholeFileIsHandedToItsNewOwnerNotRemoved(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "resolv.conf")
if err := os.WriteFile(path, []byte(theOriginal), 0o644); err != nil {
t.Fatal(err)
}
keep := KeepIn(filepath.Join(dir, "kept"))
apply := func(raw string, known store.State) (Report, store.State) {
t.Helper()
report, state, err := ApplyKeeping(context.Background(), archHost(t), parse(t, raw), known,
store.OriginDeclared, watching(t, path), nil, nil, keep)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
return report, state
}
// The first owner writes over the machine's file, keeping the original.
_, state := apply(heldBy(t, path, "resolv-conf", firstOwners), store.State{})
first, _ := state.Find("resolv-conf.fact-resolvers")
if first.Kept == "" {
t.Fatal("the machine's original was not kept before the first write")
}
// The uplink takes it over in one apply.
report, state := apply(heldBy(t, path, "networkmanager", newOwners), state)
if got, _ := os.ReadFile(path); string(got) != newOwners {
t.Fatalf("after the handover the file holds %q", got)
}
for _, o := range report.Outcomes {
if o.ID == "resolv-conf.fact-resolvers" && o.Action != "forgotten" {
t.Errorf("the record going was %q, not forgotten: %s", o.Action, o.Detail)
}
}
if _, still := state.Find("resolv-conf.fact-resolvers"); still {
t.Error("the record going is still recorded after its file was handed over")
}
now, _ := state.Find("networkmanager.fact-resolvers")
if now.Kept != first.Kept {
t.Errorf("the new owner keeps the original at %q; the first kept it at %q", now.Kept, first.Kept)
}
if kept, _ := os.ReadFile(now.Kept); string(kept) != theOriginal {
t.Errorf("what the new owner would give back is %q, not the machine's original", kept)
}
if aside, _ := filepath.Glob(path + ".removed-*"); len(aside) > 0 {
t.Errorf("the file was moved aside on the way: %v", aside)
}
// Steady from here, and undeclared the machine's original comes back — not the first owner's.
report, state = apply(heldBy(t, path, "networkmanager", newOwners), state)
for _, o := range report.Outcomes {
if o.ID == "networkmanager.fact-resolvers" && o.Action != "unchanged" {
t.Errorf("a second apply was %q: %s", o.Action, o.Detail)
}
}
if _, _, err := ApplyKeeping(context.Background(), archHost(t), somethingElse(t), state,
store.OriginDeclared, recordingServices(new([]string)), nil, nil, keep); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); string(got) != theOriginal {
t.Errorf("undeclaring the new owner left %q; the machine's original goes back", got)
}
}
// A file the mesh made where there was none is handed over the same way, and is still the mesh's —
// undeclared, it goes.
func TestAFileTheMeshMadeIsHandedOverAndStillGoesWithItsLastOwner(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
run := watching(t, path)
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
_, state, err = Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "dhcpcd", newOwners)),
state, store.OriginDeclared, run, nil, nil)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); string(got) != newOwners {
t.Fatalf("after the handover the file holds %q", got)
}
if _, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared,
recordingServices(new([]string)), nil, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("the mesh's own file stayed after its last owner was undeclared: %v", err)
}
}
// A file edited on the machine since the first owner wrote it is still corrected and said so by the
// new owner: the handover judges drift against what this host last wrote there.
func TestAHandedOverFileChangedOnTheMachineIsSaidCorrected(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
_ = os.WriteFile(path, []byte("nameserver 1.1.1.1\n"), 0o644)
report, _, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "systemd-networkd", newOwners)),
state, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, o := range report.Outcomes {
if o.ID == "systemd-networkd.fact-resolvers" && o.Action != "corrected" {
t.Errorf("a handed-over file changed on the machine was %q: %s", o.Action, o.Detail)
}
}
}
// The preview says the same before it happens.
func TestThePlanHandsAFileOverRatherThanRemovingIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "resolv.conf")
_, state, err := Apply(context.Background(), archHost(t), parse(t, heldBy(t, path, "resolv-conf", firstOwners)),
store.State{}, store.OriginDeclared, recordingServices(new([]string)), nil, nil)
if err != nil {
t.Fatal(err)
}
for _, step := range Plan(parse(t, heldBy(t, path, "networkmanager", newOwners)), state, store.OriginDeclared) {
if step.ID == "resolv-conf.fact-resolvers" && (step.Verb != "forget" || !strings.Contains(step.Why, "networkmanager.fact-resolvers")) {
t.Errorf("the plan would %s the file being handed over: %s", step.Verb, step.Why)
}
}
}