mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
235 lines
6.9 KiB
Go
235 lines
6.9 KiB
Go
// Package integration holds the tests that need a real network to mean
|
|
// anything.
|
|
//
|
|
// Everything in the transport package's own tests runs on loopback, which can
|
|
// show that a socket was configured but not where its packets went. Spec 10 is
|
|
// explicit that the exit has to be observed rather than asserted, and these
|
|
// tests are that observation: two lines whose gateways answer on the SAME
|
|
// address with different content, so the only thing that can decide which one a
|
|
// request reaches is the device binding.
|
|
//
|
|
// They need a topology that only root with iproute2 and veth can build, which
|
|
// on this project means the OpenWrt guest.
|
|
// .codex/go-loop/tools/netns_gate.py builds it, runs this, and tears it down;
|
|
// evidence/m05-environment.json records why the development host cannot.
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/transport"
|
|
)
|
|
|
|
// line describes one of the two lines the topology provides.
|
|
type line struct {
|
|
device string
|
|
source netip.Addr
|
|
answer string
|
|
}
|
|
|
|
// topology reads what netns_gate.py built. The test says what it needs and the
|
|
// setup script says what it made, rather than both hard-coding the same
|
|
// addresses in two places that can drift apart.
|
|
func topology(t *testing.T) (gateway string, one, two line) {
|
|
t.Helper()
|
|
if os.Getenv("SMART_SRUN_NETNS") != "1" {
|
|
t.Skip("no namespace topology; run .codex/go-loop/tools/netns_gate.py")
|
|
}
|
|
|
|
must := func(name string) string {
|
|
value := os.Getenv(name)
|
|
if value == "" {
|
|
t.Fatalf("%s is not set; the topology is incomplete", name)
|
|
}
|
|
return value
|
|
}
|
|
address := func(name string) netip.Addr {
|
|
parsed, err := netip.ParseAddr(must(name))
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
return must("SMART_SRUN_GATEWAY"),
|
|
line{must("SMART_SRUN_LINE1_DEVICE"), address("SMART_SRUN_LINE1_SOURCE"),
|
|
must("SMART_SRUN_LINE1_ANSWER")},
|
|
line{must("SMART_SRUN_LINE2_DEVICE"), address("SMART_SRUN_LINE2_SOURCE"),
|
|
must("SMART_SRUN_LINE2_ANSWER")}
|
|
}
|
|
|
|
func bindingFor(l line, generation uint64) domain.Binding {
|
|
return domain.Binding{
|
|
LogicalIface: l.device,
|
|
L3Device: l.device,
|
|
SourceIPv4: l.source,
|
|
// The gateway address is reachable without a lookup, so the resolver is
|
|
// never consulted; a server is still required for the client to be
|
|
// built at all.
|
|
DNSServers: []netip.Addr{netip.MustParseAddr("192.0.2.53")},
|
|
Generation: generation,
|
|
}
|
|
}
|
|
|
|
func fetch(t *testing.T, client *transport.Client, url string) (string, error) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(t.Context(), 8*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest: %v", err)
|
|
}
|
|
response, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer transport.DrainAndClose(response.Body)
|
|
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, 4096))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(body)), nil
|
|
}
|
|
|
|
// T17 -- each line reaches its own gateway, and the two gateways share an
|
|
// address.
|
|
//
|
|
// The host has a route to that address through each device at different
|
|
// metrics. A client that set only the source would follow the metric and reach
|
|
// the same namespace both times, so the second line's answer is the whole
|
|
// evidence: it can only have come from the device binding.
|
|
func TestEachLineReachesItsOwnGateway(t *testing.T) {
|
|
gateway, one, two := topology(t)
|
|
url := "http://" + gateway + "/answer"
|
|
|
|
for _, l := range []line{one, two} {
|
|
t.Run(l.device, func(t *testing.T) {
|
|
client, err := transport.NewClient(bindingFor(l, 1))
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
body, err := fetch(t, client, url)
|
|
if err != nil {
|
|
t.Fatalf("fetch over %s: %v", l.device, err)
|
|
}
|
|
if body != l.answer {
|
|
t.Errorf("%s reached the gateway answering %q, want %q -- the "+
|
|
"request left by the wrong line", l.device, body, l.answer)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// T17 -- and they do so at the same time without crossing.
|
|
//
|
|
// One shared route table, one shared kernel, two workers. If the binding were a
|
|
// process-wide setting rather than a per-socket one, this is where it would
|
|
// show.
|
|
func TestConcurrentLinesDoNotCrossOver(t *testing.T) {
|
|
gateway, one, two := topology(t)
|
|
url := "http://" + gateway + "/answer"
|
|
|
|
type result struct {
|
|
device string
|
|
body string
|
|
err error
|
|
}
|
|
results := make(chan result, 40)
|
|
|
|
var wait sync.WaitGroup
|
|
for _, l := range []line{one, two} {
|
|
for range 10 {
|
|
wait.Add(1)
|
|
go func(l line) {
|
|
defer wait.Done()
|
|
client, err := transport.NewClient(bindingFor(l, 1))
|
|
if err != nil {
|
|
results <- result{l.device, "", err}
|
|
return
|
|
}
|
|
defer client.Close()
|
|
body, err := fetch(t, client, url)
|
|
results <- result{l.device, body, err}
|
|
}(l)
|
|
}
|
|
}
|
|
wait.Wait()
|
|
close(results)
|
|
|
|
expected := map[string]string{one.device: one.answer, two.device: two.answer}
|
|
seen := 0
|
|
for item := range results {
|
|
seen++
|
|
if item.err != nil {
|
|
t.Errorf("%s: %v", item.device, item.err)
|
|
continue
|
|
}
|
|
if item.body != expected[item.device] {
|
|
t.Errorf("%s reached the gateway answering %q, want %q -- two "+
|
|
"concurrent lines crossed", item.device, item.body,
|
|
expected[item.device])
|
|
}
|
|
}
|
|
if seen != 20 {
|
|
t.Errorf("collected %d results, want 20", seen)
|
|
}
|
|
}
|
|
|
|
// T14 -- a line whose device is down fails, even though the other line works
|
|
// and the default route is fine.
|
|
//
|
|
// This is the failure a client without a device binding gets wrong: the route
|
|
// table still has a way to the gateway, so an unbound request succeeds and the
|
|
// user is told they are online on a line that is not carrying anything.
|
|
func TestALineWhoseDeviceIsDownFailsWhileTheOtherWorks(t *testing.T) {
|
|
gateway, one, two := topology(t)
|
|
url := "http://" + gateway + "/answer"
|
|
|
|
// Take the second line's device down and put it back afterwards, whatever
|
|
// happens in between.
|
|
if err := setLink(two.device, false); err != nil {
|
|
t.Skipf("cannot bring %s down: %v", two.device, err)
|
|
}
|
|
t.Cleanup(func() { setLink(two.device, true) })
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
down, err := transport.NewClient(bindingFor(two, 2))
|
|
if err == nil {
|
|
defer down.Close()
|
|
body, fetchErr := fetch(t, down, url)
|
|
if fetchErr == nil {
|
|
t.Errorf("a request over the down line succeeded and returned %q; "+
|
|
"it left by the line that was still up", body)
|
|
}
|
|
}
|
|
|
|
// The other line is unaffected, so the failure above is the device being
|
|
// down and not the topology having collapsed.
|
|
up, err := transport.NewClient(bindingFor(one, 2))
|
|
if err != nil {
|
|
t.Fatalf("NewClient for the working line: %v", err)
|
|
}
|
|
defer up.Close()
|
|
|
|
body, err := fetch(t, up, url)
|
|
if err != nil {
|
|
t.Fatalf("the working line failed too: %v", err)
|
|
}
|
|
if body != one.answer {
|
|
t.Errorf("the working line answered %q, want %q", body, one.answer)
|
|
}
|
|
}
|