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) } } }