internal/agent/netenv/netenv_test.go
Ref: Size: 17.5 KiB History
package netenv
import (
"context"
"errors"
"net"
"os"
"strings"
"testing"
"github.com/a73x/eitri/internal/agent/exec"
"github.com/a73x/eitri/internal/agent/state"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stateMAC re-exposes state.MAC for the test without a separate import block.
func stateMAC(vmID string) string { return state.MAC(vmID) }
// stateNetMAC re-exposes state.NetMAC — the second NIC's address — likewise.
func stateNetMAC(vmID string) string { return state.NetMAC(vmID) }
type call struct {
name string
args string
}
// recorder returns a Runner fake that records all commands.
// out maps "name args" → stdout; errs maps "name args" → error.
// Either map may be nil.
func recorder(out map[string]string, errs map[string]error) (exec.Runner, *[]call) {
var calls []call
return func(ctx context.Context, name string, args ...string) (string, error) {
joined := strings.Join(args, " ")
key := name + " " + joined
calls = append(calls, call{name, joined})
return out[key], errs[key]
}, &calls
}
func joinCalls(calls *[]call) string {
var sb strings.Builder
for _, c := range *calls {
sb.WriteString(c.name + " " + c.args + "\n")
}
return sb.String()
}
func TestEnsureBridgeSetsUpGatewayForwardingAndNAT(t *testing.T) {
// Fresh host: `ip link show` errors (bridge absent) so link-add runs.
errs := map[string]error{
"ip link show dev eitri0": errors.New("Device \"eitri0\" does not exist."),
}
run, calls := recorder(bridgeAddrShow, errs)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
require.NoError(t, n.EnsureBridge(context.Background()))
all := joinCalls(calls)
assert.Contains(t, all, "ip link add eitri0 type bridge")
// Idempotent add-or-update — never the fragile, version-specific `ip addr add`.
assert.Contains(t, all, "ip addr replace 10.77.1.1/24 dev eitri0")
assert.NotContains(t, all, "ip addr add")
assert.Contains(t, all, "net.ipv4.ip_forward=1")
assert.Contains(t, all, "masquerade")
assert.Contains(t, all, "10.77.1.0/24")
}
// TestEnsureBridgeIdempotentOnRestart is the regression test for the smoke-test
// failure: a prior run left eitri0 up with the gateway address, and the agent
// died at `ip addr add` because the duplicate-address message ("Error: ipv4:
// Address already assigned.") was not in best()'s allow-list. EnsureBridge must
// now survive a restart with the bridge already up — it existence-checks the
// link and uses `ip addr replace`, depending on no error-string parsing.
func TestEnsureBridgeIdempotentOnRestart(t *testing.T) {
out := map[string]string{
"ip link show dev eitri0": "7: eitri0: <BROADCAST,MULTICAST,UP> mtu 1500 state UP",
"ip -o addr show dev eitri0": "7: eitri0 inet 10.77.1.1/24 scope global eitri0",
}
// If the code ever regresses to `ip addr add`, fail it the real-world way.
errs := map[string]error{
"ip addr add 10.77.1.1/24 dev eitri0": errors.New("Error: ipv4: Address already assigned."),
}
run, calls := recorder(out, errs)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
require.NoError(t, n.EnsureBridge(context.Background()),
"restart with existing bridge+address must succeed")
all := joinCalls(calls)
assert.Contains(t, all, "ip addr replace 10.77.1.1/24 dev eitri0")
assert.NotContains(t, all, "ip addr add", "must use replace, not add")
assert.NotContains(t, all, "ip link add", "bridge exists → no link add")
}
func TestTapLifecycle(t *testing.T) {
// Fresh tap: the existence probe fails, so the add runs.
errs := map[string]error{
"ip link show dev eit-abc123": errors.New(`Device "eit-abc123" does not exist.`),
}
run, calls := recorder(nil, errs)
n, _ := New(run, "10.77.1.0/24", nil)
require.NoError(t, n.CreateTap(context.Background(), "abc123", "10.77.1.5", ""))
all := joinCalls(calls)
assert.Contains(t, all, "ip tuntap add dev eit-abc123 mode tap")
assert.Contains(t, all, "ip link set eit-abc123 master eitri0")
}
// TestEnsureBridgeFlushPrecedesRuleAdd asserts that nft flush chain is issued
// before nft add rule on every call to EnsureBridge. Running it N times keeps
// the ruleset bounded by construction: each flush clears prior rules before the
// new one is appended.
func TestEnsureBridgeFlushPrecedesRuleAdd(t *testing.T) {
addrShowOut := "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0"
out := map[string]string{
"ip -o addr show dev eitri0": addrShowOut,
}
run, calls := recorder(out, nil)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
// Call EnsureBridge twice — simulating two agent restarts.
require.NoError(t, n.EnsureBridge(context.Background()))
require.NoError(t, n.EnsureBridge(context.Background()))
all := joinCalls(calls)
// Count flushes and rule-adds.
flushCount := strings.Count(all, "nft flush chain ip eitri postrouting")
addCount := strings.Count(all, "nft add rule ip eitri postrouting")
assert.Equal(t, 2, flushCount, "expected one flush per EnsureBridge call")
assert.Equal(t, 2, addCount, "expected one add-rule per EnsureBridge call")
// Each flush must appear before its corresponding rule-add in call order.
flushIdx := -1
addIdx := -1
for i, c := range *calls {
line := c.name + " " + c.args
if strings.Contains(line, "nft flush chain ip eitri postrouting") && flushIdx == -1 {
flushIdx = i
}
if strings.Contains(line, "nft add rule ip eitri postrouting") && addIdx == -1 {
addIdx = i
}
}
assert.Less(t, flushIdx, addIdx, "first flush must precede first rule-add")
}
// TestEnsureBridgeGatewayConflictDetected: the post-condition check catches a
// genuine failure where the gateway address did not end up on the bridge —
// EnsureBridge must return an explicit error instead of proceeding with a
// gatewayless bridge.
func TestEnsureBridgeGatewayConflictDetected(t *testing.T) {
// addr-show returns output WITHOUT the gateway.
out := map[string]string{
"ip -o addr show dev eitri0": "2: eitri0 inet scope global eitri0",
}
run, _ := recorder(out, nil)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
err = n.EnsureBridge(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "gateway IP 10.77.1.1 not present on eitri0")
}
// TestEnsureBridgePlainMasquerade: the masquerade rule has no interface
// exclusions — the bridge is a pure masqueraded underlay.
func TestEnsureBridgePlainMasquerade(t *testing.T) {
out := map[string]string{
"ip -o addr show dev eitri0": "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0",
}
run, calls := recorder(out, nil)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
require.NoError(t, n.EnsureBridge(context.Background()))
all := joinCalls(calls)
assert.Contains(t, all, "masquerade", "masquerade rule must still be added")
assert.NotContains(t, all, "oifname", "no interface exclusions in the rule")
}
// CreateTap must tolerate "File exists" / "already exists" so it is idempotent.
// The marker may appear in stdout OR in the error message — both are tolerated.
func TestCreateTapToleratesAlreadyExists(t *testing.T) {
cases := []struct {
name string
out string
errText string
}{
{"file-exists-in-err", "", "ioctl(TUNSETIFF): File exists"},
{"already-exists-in-err", "", "Error: Device already exists"},
{"file-exists-in-stdout", "File exists", "exit status 2"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
key := "ip tuntap add dev eit-x mode tap"
run, _ := recorder(
map[string]string{key: tc.out},
map[string]error{
key: errors.New(tc.errText),
// TOCTOU window: probe says absent, add still races an
// exists-style failure — the tolerance must still apply.
"ip link show dev eit-x": errors.New(`Device "eit-x" does not exist.`),
},
)
n, _ := New(run, "10.77.1.0/24", nil)
require.NoError(t, n.CreateTap(context.Background(), "x", "10.77.1.5", ""),
"already-exists must be tolerated for idempotency")
})
}
}
// CreateTap must still surface a genuine (non-tolerated) error.
func TestCreateTapPropagatesRealError(t *testing.T) {
key := "ip tuntap add dev eit-x mode tap"
run, _ := recorder(nil, map[string]error{
key: errors.New("Operation not permitted"),
"ip link show dev eit-x": errors.New(`Device "eit-x" does not exist.`),
})
n, _ := New(run, "10.77.1.0/24", nil)
err := n.CreateTap(context.Background(), "x", "10.77.1.5", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "Operation not permitted")
}
// DeleteTap must tolerate "Cannot find device" (already cleaned up), in stdout
// or in the error message, and propagate anything else.
func TestDeleteTapToleratesMissingDevice(t *testing.T) {
key := "ip link del eit-x"
run, _ := recorder(nil, map[string]error{key: errors.New(`Cannot find device "eit-x"`)})
n, _ := New(run, "10.77.1.0/24", nil)
require.NoError(t, n.DeleteTap(context.Background(), "x"))
run2, _ := recorder(map[string]string{key: "Cannot find device"}, map[string]error{key: errors.New("exit status 1")})
n2, _ := New(run2, "10.77.1.0/24", nil)
require.NoError(t, n2.DeleteTap(context.Background(), "x"))
run3, _ := recorder(nil, map[string]error{key: errors.New("RTNETLINK answers: Operation not permitted")})
n3, _ := New(run3, "10.77.1.0/24", nil)
require.Error(t, n3.DeleteTap(context.Background(), "x"))
}
// TestCreateTapIdempotentWhenTapExists is the regression test for the sandbox
// failure: a create-retry re-ran CreateTap on a tap left by the previous
// attempt (UP, enslaved to eitri0), and `ip tuntap add` failed with
// "ioctl(TUNSETIFF): Device or resource busy" — which is NOT in the tolerated
// string list, so the retry's real root-cause error (disk guard) was masked
// by a tap artifact. Like EnsureBridge, CreateTap must existence-check the
// link and skip the add instead of parsing error strings.
func TestCreateTapIdempotentWhenTapExists(t *testing.T) {
out := map[string]string{
"ip link show dev eit-busy": "4: eit-busy: <NO-CARRIER,BROADCAST,MULTICAST,UP> master eitri0 state DOWN",
}
// If the code ever regresses to an unconditional add, fail it the
// real-world way.
errs := map[string]error{
"ip tuntap add dev eit-busy mode tap": errors.New("ioctl(TUNSETIFF): Device or resource busy"),
}
run, calls := recorder(out, errs)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
n.isTap = func(string) bool { return true } // the leftover IS a real tap
require.NoError(t, n.CreateTap(context.Background(), "busy", "10.77.1.5", ""),
"CreateTap on an existing tap must succeed")
all := joinCalls(calls)
assert.NotContains(t, all, "ip tuntap add", "tap exists → no add")
assert.Contains(t, all, "ip link set eit-busy master eitri0", "enslave stays (idempotent)")
assert.Contains(t, all, "ip link set eit-busy up", "up stays (idempotent)")
}
// TestCreateTapRejectsNonTapDevice pins the collision guard: when a link
// with the tap's name exists but is NOT a tap (stale dummy/veth or a name
// collision), CreateTap must fail loudly naming the conflict — silently
// enslaving it would surface later as an illegible cloud-hypervisor error.
func TestCreateTapRejectsNonTapDevice(t *testing.T) {
out := map[string]string{
"ip link show dev eit-clash": "5: eit-clash: <BROADCAST> state DOWN", // exists
}
run, calls := recorder(out, nil)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
n.isTap = func(tap string) bool { return false } // not a tun/tap device
err = n.CreateTap(context.Background(), "clash", "10.77.1.5", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "not a TAP device")
var p interface{ Permanent() bool }
assert.True(t, errors.As(err, &p) && p.Permanent(), "conflict never self-resolves — must be permanent")
all := joinCalls(calls)
assert.NotContains(t, all, "master", "must not enslave a conflicting device")
assert.NotContains(t, all, "ip tuntap add", "guard path must not attempt a create")
}
// TestCreateTapAcceptsExistingRealTap pins the counterpart: an existing
// genuine tap passes the type check and is (idempotently) enslaved.
func TestCreateTapAcceptsExistingRealTap(t *testing.T) {
out := map[string]string{
"ip link show dev eit-ok": "5: eit-ok: <NO-CARRIER,BROADCAST,MULTICAST,UP> master eitri0",
}
run, calls := recorder(out, nil)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
n.isTap = func(tap string) bool { return true }
require.NoError(t, n.CreateTap(context.Background(), "ok", "10.77.1.5", ""))
all := joinCalls(calls)
assert.NotContains(t, all, "ip tuntap add")
assert.Contains(t, all, "ip link set eit-ok master eitri0")
}
// TestSysfsIsTap exercises the production sysfs probe against a fabricated
// /sys-like tree: an L2 TAP (IFF_TAP set), an L3 TUN (bit clear), a
// non-tun-driver link (no tun_flags), and an absent link.
func TestSysfsIsTap(t *testing.T) {
// The probe reads /sys/class/net/<name>/tun_flags; point it at a temp
// tree via a tiny indirection so the test needs no root or real devices.
root := t.TempDir()
write := func(name, flags string) {
dir := root + "/" + name
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(dir+"/tun_flags", []byte(flags), 0o644))
}
write("eit-tap", "0x0002") // IFF_TAP
write("eit-tun", "0x0001") // IFF_TUN (L3)
require.NoError(t, os.MkdirAll(root+"/eth0", 0o755)) // no tun_flags
isTap := func(name string) bool { return sysfsIsTapAt(root, name) }
assert.True(t, isTap("eit-tap"), "IFF_TAP link is a tap")
assert.False(t, isTap("eit-tun"), "IFF_TUN link is not an L2 tap")
assert.False(t, isTap("eth0"), "non-tun link is not a tap")
assert.False(t, isTap("absent"), "missing link is not a tap")
}
// The tap name-collision failure is marked permanent (reconcile terminal-fails
// on it in one attempt); TestCreateTapRejectsNonTapDevice pins that on the
// production path, and internal/agent/permanent owns the marker's unit test.
func TestCreateTapAddsReservationAndTapCommands(t *testing.T) {
errs := map[string]error{
"ip link show dev eit-vm-abc12": errors.New("does not exist"),
}
run, calls := recorder(nil, errs)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
require.NoError(t, n.CreateTap(context.Background(), "vm-abc12345", "10.77.1.7", ""))
all := joinCalls(calls)
assert.Contains(t, all, "ip tuntap add dev eit-vm-abc12 mode tap")
assert.Contains(t, all, "ip link set eit-vm-abc12 master eitri0")
mac, _ := net.ParseMAC(stateMAC("vm-abc12345"))
ip, ok := n.dhcp.Lookup(mac)
require.True(t, ok)
assert.Equal(t, "10.77.1.7", ip.String())
}
func TestReserveIPAllocatesAndRecordsReservation(t *testing.T) {
noop := func(_ context.Context, _ string, _ ...string) (string, error) { return "", nil }
n, err := New(noop, "10.77.1.0/24", nil)
require.NoError(t, err)
ip, err := n.ReserveIP("vm-alpha")
require.NoError(t, err)
assert.Equal(t, "10.77.1.2", ip)
mac, _ := net.ParseMAC(stateMAC("vm-alpha"))
got, ok := n.dhcp.Lookup(mac)
require.True(t, ok, "ReserveIP must record the DHCP reservation")
assert.Equal(t, "10.77.1.2", got.String())
again, err := n.ReserveIP("vm-alpha")
require.NoError(t, err)
assert.Equal(t, ip, again, "ReserveIP is sticky per VM")
}
func TestDeleteTapRemovesReservationAndTap(t *testing.T) {
errs := map[string]error{
"ip link show dev eit-vm-abc12": errors.New("does not exist"),
}
run, calls := recorder(nil, errs)
n, err := New(run, "10.77.1.0/24", nil)
require.NoError(t, err)
require.NoError(t, n.CreateTap(context.Background(), "vm-abc12345", "10.77.1.7", ""))
require.NoError(t, n.DeleteTap(context.Background(), "vm-abc12345"))
assert.Contains(t, joinCalls(calls), "ip link del eit-vm-abc12")
mac, _ := net.ParseMAC(stateMAC("vm-abc12345"))
_, ok := n.dhcp.Lookup(mac)
assert.False(t, ok, "reservation must be gone after DeleteTap")
}
func TestAddressReportsTheReservationWithoutAllocating(t *testing.T) {
noop := func(_ context.Context, _ string, _ ...string) (string, error) { return "", nil }
n, err := New(noop, "10.77.1.0/24", nil)
require.NoError(t, err)
assert.Empty(t, n.Address("vm-alpha"), "an unreserved VM has no address")
ip, err := n.ReserveIP("vm-alpha")
require.NoError(t, err)
assert.Equal(t, ip, n.Address("vm-alpha"))
assert.Empty(t, n.Address("vm-beta"),
"Address must never allocate — only ReserveIP does")
beta, err := n.ReserveIP("vm-beta")
require.NoError(t, err)
assert.Equal(t, "10.77.1.3", beta,
"the read above must not have consumed an address")
}
func TestReserveIPKeepsAPreloadedAddress(t *testing.T) {
noop := func(_ context.Context, _ string, _ ...string) (string, error) { return "", nil }
n, err := New(noop, "10.77.1.0/24", nil)
require.NoError(t, err)
// The reservation table is in-memory and preloaded at agent startup from
// durable records. A VM that survived a restart must get the address its
// guest already holds back, not the next free one.
n.AddReservation("vm-survivor", "10.77.1.55")
ip, err := n.ReserveIP("vm-survivor")
require.NoError(t, err)
assert.Equal(t, "10.77.1.55", ip)
assert.Equal(t, "10.77.1.55", n.Address("vm-survivor"))
// A preloaded address is held against every other VM too: one address
// served to two guests breaks connectivity for both.
other, err := n.ReserveIP("vm-new")
require.NoError(t, err)
assert.NotEqual(t, ip, other)
}
func TestTapNameIsTruncatedBelowIFNAMSIZ(t *testing.T) {
noop := func(_ context.Context, _ string, _ ...string) (string, error) { return "", nil }
n, err := New(noop, "10.77.1.0/24", nil)
require.NoError(t, err)
assert.Equal(t, "eit-vm1", n.TapName("vm1"))
assert.Equal(t, "eit-abcdefgh", n.TapName("abcdefghijklmnop"))
assert.LessOrEqual(t, len(n.TapName("abcdefghijklmnop")), 15, "IFNAMSIZ is 16 including NUL")
}