a73x

internal/agent/run/reservations_test.go

Ref:   Size: 5.4 KiB   History

package run

import (
	"bytes"
	"log/slog"
	"os"
	"path/filepath"
	"testing"

	"github.com/a73x/eitri/internal/agent/state"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// fakeReservations records what the replay pinned and what it adopted. The real
// table lives behind a DHCP responder on a host bridge; the replay only has to
// decide which of the two a surviving guest needs.
type fakeReservations struct {
	pinned  map[string]string
	adopted map[string][2]string // vmID -> {network, ip}
}

func newFakeReservations() *fakeReservations {
	return &fakeReservations{pinned: map[string]string{}, adopted: map[string][2]string{}}
}

func (f *fakeReservations) AddReservation(vmID, ip string) { f.pinned[vmID] = ip }

func (f *fakeReservations) AdoptNetwork(vmID, network, ip string) {
	f.adopted[vmID] = [2]string{network, ip}
}

// storeWithRecords opens a store in a temp dir and persists recs, returning the
// store and its directory.
func storeWithRecords(t *testing.T, recs ...state.Record) (*state.Store, string) {
	t.Helper()
	dir := t.TempDir()
	st, err := state.Open(dir)
	require.NoError(t, err)
	for _, rec := range recs {
		require.NoError(t, st.SaveVM(rec))
	}
	return st, dir
}

// captureLogs redirects the default logger into a buffer for the duration of
// the test — replayReservations reports a state-load failure the only way a
// caller-less side effect can, by logging it.
func captureLogs(t *testing.T) *bytes.Buffer {
	t.Helper()
	var logs bytes.Buffer
	prev := slog.Default()
	slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
	t.Cleanup(func() { slog.SetDefault(prev) })
	return &logs
}

// TestReplayReservationsPinsEveryRecordedAddress is the whole point of the
// replay: a guest that survived the agent restart keeps its address, so the
// responder cannot hand it to a VM created afterwards.
func TestReplayReservationsPinsEveryRecordedAddress(t *testing.T) {
	st, _ := storeWithRecords(t,
		state.Record{Spec: state.VMSpec{VMID: "vm-a"}, IP: "10.77.1.5"},
		state.Record{Spec: state.VMSpec{VMID: "vm-b"}, IP: "10.77.1.6"},
	)

	table := newFakeReservations()
	replayReservations(st, table)

	assert.Equal(t, map[string]string{"vm-a": "10.77.1.5", "vm-b": "10.77.1.6"}, table.pinned)
}

// TestReplayReservationsSkipsRecordWithoutAddress pins that a record with no
// recorded address contributes nothing: there is no address to protect, and
// pinning an empty one would claim a reservation the guest does not hold.
func TestReplayReservationsSkipsRecordWithoutAddress(t *testing.T) {
	st, _ := storeWithRecords(t,
		state.Record{Spec: state.VMSpec{VMID: "vm-addressed"}, IP: "10.77.1.7"},
		state.Record{Spec: state.VMSpec{VMID: "vm-unaddressed"}},
	)

	table := newFakeReservations()
	replayReservations(st, table)

	assert.Equal(t, map[string]string{"vm-addressed": "10.77.1.7"}, table.pinned)
}

// TestReplayReservationsRebuildsBothNICs pins the additive rebuild: a networked
// guest has two NICs and needs both halves back. Its NAT reservation is pinned
// like every other guest's — that is the address the gate splices to and the
// one it must not be renumbered out of — AND its named NIC is adopted with the
// last address the site's DHCP server was seen granting it, which this host
// pins nowhere because it was never this host's to hand out.
func TestReplayReservationsRebuildsBothNICs(t *testing.T) {
	st, _ := storeWithRecords(t,
		state.Record{Spec: state.VMSpec{VMID: "vm-nat"}, IP: "10.77.1.5"},
		state.Record{
			Spec:      state.VMSpec{VMID: "vm-lan", Network: "lan"},
			IP:        "10.77.1.6",
			NetworkIP: "192.168.0.7",
		},
		// Booted, but its guest has not been heard on the LAN yet: the
		// attachment still has to be rebuilt, or nothing re-arms the discovery
		// that will report the address when it comes.
		state.Record{Spec: state.VMSpec{VMID: "vm-fresh", Network: "lab"}, IP: "10.77.1.7"},
	)

	table := newFakeReservations()
	replayReservations(st, table)

	assert.Equal(t, map[string]string{
		"vm-nat":   "10.77.1.5",
		"vm-lan":   "10.77.1.6",
		"vm-fresh": "10.77.1.7",
	}, table.pinned, "every guest keeps its NAT reservation, networked or not")
	assert.Equal(t, map[string][2]string{
		"vm-lan":   {"lan", "192.168.0.7"},
		"vm-fresh": {"lab", ""},
	}, table.adopted, "and only a networked guest is adopted, with the LAN address alone")
}

// TestReplayAdoptsNoNetworkForANATOnlyGuest pins the other side: the guest
// every host runs today is rebuilt exactly as it was before named networks
// existed — pinned, and adopted by nothing.
func TestReplayAdoptsNoNetworkForANATOnlyGuest(t *testing.T) {
	st, _ := storeWithRecords(t, state.Record{Spec: state.VMSpec{VMID: "vm-nat"}, IP: "10.77.1.5"})

	table := newFakeReservations()
	replayReservations(st, table)

	assert.Empty(t, table.adopted)
}

// TestReplayReservationsSurvivesStateLoadFailure pins the fail-closed
// behaviour: an unreadable state directory warns and returns, leaving the
// table empty rather than aborting the agent's startup.
func TestReplayReservationsSurvivesStateLoadFailure(t *testing.T) {
	st, dir := storeWithRecords(t, state.Record{Spec: state.VMSpec{VMID: "vm-a"}, IP: "10.77.1.5"})
	require.NoError(t, os.RemoveAll(filepath.Join(dir, "vms")))
	logs := captureLogs(t)

	table := newFakeReservations()
	replayReservations(st, table)

	assert.Empty(t, table.pinned)
	assert.Contains(t, logs.String(), "level=WARN")
	assert.Contains(t, logs.String(), "reservations are not rebuilt")
}