Drill the network judge in a throwaway machine (hq ADR 0241)

The judge's tests run against files and fakes; the drill runs it against a
real resolver file and real resolvers, rewritten as the VPN client does,
and records the statements the controller's replay raises and clears from.
This commit is contained in:
jochen
2026-10-07 18:51:45 +02:00
parent 429ea42357
commit 039abeff69
+101
View File
@@ -0,0 +1,101 @@
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=<mesh name> \
// -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=<mesh name>")
}
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)
}
}
}