a73x

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