package network import ( "encoding/json" "os" "strings" "testing" "time" "github.com/novox/mesh-host/internal/link" ) // TestDrillAResolverFileRewrittenInAThrowawayMachine is the drill of novox/hq ADR 0241: on a machine of // its own — a throwaway container, never a machine of the mesh — the resolver file is what was declared, // then rewritten the way a VPN client rewrites it, then written back; the judge must say it healthy, // unhealthy on the second look naming the writer and the names it costs, and healthy again on the first // look after. Each statement is written, as the engine says it, to MESH_NETWORK_DRILL_OUT, for the // controller's replay of the same drill (mesh-controller testdata/network-drill.json). // // Run only where MESH_NETWORK_DRILL names the mesh name to ask, because it rewrites /etc/resolv.conf: // // CGO_ENABLED=0 go test -c -o drill ./internal/network // docker run --rm -v $PWD/drill:/drill:ro -v $PWD/out:/out -e MESH_NETWORK_DRILL= \ // -e MESH_NETWORK_DRILL_OUT=/out/network-drill.json alpine /drill -test.run Drill -test.v // // The clock moves a look on per look; the file, the resolvers and the answers are the machine's own. func TestDrillAResolverFileRewrittenInAThrowawayMachine(t *testing.T) { meshName := os.Getenv("MESH_NETWORK_DRILL") if meshName == "" { t.Skip("a drill rewrites /etc/resolv.conf: run it in a throwaway container with MESH_NETWORK_DRILL=") } declared, err := os.ReadFile(ResolvConf) if err != nil { t.Fatal(err) } now := time.Now() j := New(Machine{Now: func() time.Time { return now }, Linked: func() bool { return true }}, meshName) j.Declare(string(declared), "networkmanager", true) var said []link.Health look := func(want string) Statement { t.Helper() now = now.Add(LookEvery) st, _ := j.Look(t.Context()) h := link.Health{Contract: link.ReadinessContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}, Network: &link.NetworkHealth{State: st.State, Since: st.Since.UTC(), Parts: []link.NetworkPart{}}} for _, p := range st.Parts { h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason, Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak}) } said = append(said, h) raw, _ := json.Marshal(st) t.Logf("%s", raw) if st.State != want { t.Fatalf("want %s, the judge says %s", want, st.State) } return st } write := func(content string) { t.Helper() // In place, as the VPN client's rename leaves a file of the same name: the judge reads by path. if err := os.WriteFile(ResolvConf, []byte(content), 0o644); err != nil { t.Fatal(err) } } defer write(string(declared)) look(Healthy) look(Healthy) // What the VPN client writes on connect: its header, its own resolvers — addresses that answer // nothing here — and its search domains. write("# Dynamic resolv.conf(5) file for glibc resolver(3) generated by forticlient\n" + "# The original file is backed up and will be restored after the VPN disconnects.\n" + "nameserver 192.0.2.53\nsearch corp.example\noptions timeout:1\n") look(Healthy) // one look is not a finding st := look(Unhealthy) for _, p := range st.Parts { switch p.Part { case PartResolvConf: if p.State != Unhealthy || p.Writer != "FortiClient" || p.Owner != "networkmanager" { t.Fatalf("the file is said %+v", p) } case PartNames: if p.State != Unhealthy || !strings.Contains(p.Said, "192.0.2.53") { t.Fatalf("the names through it are said %+v", p) } } } write(string(declared)) look(Healthy) if out := os.Getenv("MESH_NETWORK_DRILL_OUT"); out != "" { raw, err := json.MarshalIndent(said, "", " ") if err != nil { t.Fatal(err) } if err := os.WriteFile(out, raw, 0o644); err != nil { t.Fatal(err) } } }