a73x

internal/server/syncsvc/network_test.go

Ref:   Size: 8.0 KiB   History

package syncsvc

import (
	"testing"
	"time"

	"github.com/a73x/eitri/internal/pb"
	"github.com/a73x/eitri/internal/server/store"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestHelloAdvertisesHostNetworks is the whole basis of named-network
// admission: a create is only allowed onto a host that says it serves the
// network, and the only place a host ever says so is its Hello. If this stops
// reaching the registry, every bridged create on the fleet is refused.
func TestHelloAdvertisesHostNetworks(t *testing.T) {
	f := setup(t)
	_, err := dialHello(t, f.addr, f.fp, &pb.Hello{
		HostId: f.host.ID, Provisioner: "cloudhv", Credential: f.cred,
		HostNetworks: []string{"lan", "lab"},
	})
	require.NoError(t, err)

	require.Eventually(t, func() bool {
		st, ok := f.reg.Get(f.host.ID)
		return ok && len(st.HostNetworks) == 2
	}, 2*time.Second, 20*time.Millisecond, "the Hello's networks must reach the registry")
	st, _ := f.reg.Get(f.host.ID)
	assert.Equal(t, []string{"lan", "lab"}, st.HostNetworks)
}

// TestSnapshotCarriesTheVMsNetwork: the row records which underlay a guest was
// admitted onto, and the snapshot is the only way that reaches the agent that
// builds it. A NAT guest carries the empty name — the agent's default path —
// and neither one is inferred anywhere downstream.
func TestSnapshotCarriesTheVMsNetwork(t *testing.T) {
	f := setup(t)
	require.NoError(t, f.st.CreateVM(store.VM{
		ID: "vm1", HostID: f.host.ID, Name: "bridged", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Network: "lan",
	}))
	require.NoError(t, f.st.CreateVM(store.VM{
		ID: "vm2", HostID: f.host.ID, Name: "natted", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))

	snap, err := f.svc.buildSnapshot(f.host.ID)
	require.NoError(t, err)
	got := map[string]string{}
	for _, v := range snap.GetVms() {
		got[v.GetVmId()] = v.GetNetwork()
	}
	assert.Equal(t, map[string]string{"vm1": "lan", "vm2": ""}, got)
}

// networkedVM creates one VM on the named network `lan` for the report tests.
func networkedVM(t *testing.T, f *fixture, id string) {
	t.Helper()
	require.NoError(t, f.st.CreateVM(store.VM{
		ID: id, HostID: f.host.ID, Name: id, ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Network: "lan",
	}))
}

// TestApplyReportRecordsBothAddresses pins the inbound leg of the second NIC:
// the address the site's DHCP granted is durable beside — never instead of —
// the private-fabric address the gate and every exposure aim at.
func TestApplyReportRecordsBothAddresses(t *testing.T) {
	f := setup(t)
	networkedVM(t, f, "vm1")

	f.svc.applyReport(f.host.ID, &pb.Report{
		Vms: []*pb.VMStatus{{
			VmId: "vm1", PowerState: "running", Phase: "ready",
			Ip: "10.77.1.2", NetworkIp: "192.168.0.42",
		}},
	})

	vm, err := f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "10.77.1.2", vm.AssignedIP)
	assert.Equal(t, "192.168.0.42", vm.NetworkIP)
}

// TestApplyReportRecordsTheLeaseBeforeTheGuestIsReady: the site's DHCP answers
// on its own clock, which is not the lifecycle's. A lease that lands while the
// guest is still booting is recorded then — the status write's ready/failed
// filter must not hold the address hostage.
func TestApplyReportRecordsTheLeaseBeforeTheGuestIsReady(t *testing.T) {
	f := setup(t)
	networkedVM(t, f, "vm1")

	f.svc.applyReport(f.host.ID, &pb.Report{
		Vms: []*pb.VMStatus{{VmId: "vm1", PowerState: "running", Phase: "creating", NetworkIp: "192.168.0.42"}},
	})

	vm, err := f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.42", vm.NetworkIP)
	assert.Equal(t, "pending", vm.Status, "the lifecycle write still waits for ready or failed")
}

// TestApplyReportKeepsTheLastKnownLease: an agent restarted before its guest
// renews reports no address on the named NIC, and silence is not "the guest
// left the network". The stored address stands until a new one replaces it.
func TestApplyReportKeepsTheLastKnownLease(t *testing.T) {
	f := setup(t)
	networkedVM(t, f, "vm1")
	ready := func(netIP string) *pb.Report {
		return &pb.Report{Vms: []*pb.VMStatus{{
			VmId: "vm1", PowerState: "running", Phase: "ready", Ip: "10.77.1.2", NetworkIp: netIP,
		}}}
	}

	f.svc.applyReport(f.host.ID, ready("192.168.0.42"))
	f.svc.applyReport(f.host.ID, ready(""))
	vm, err := f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.42", vm.NetworkIP, "an empty report is 'not known', not 'gone'")

	// A renewal onto a different address is followed, not stuck.
	f.svc.applyReport(f.host.ID, ready("192.168.0.77"))
	vm, err = f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.77", vm.NetworkIP)

	// An unusable address (what a guest shows when nothing answered) is one
	// the store drops and keeps the prior value for — the cache still commits
	// the raw report, so this proves the divergence between what netIPTrack
	// remembers and what the row holds is harmless: the next GOOD address
	// still reads as a change against the cache and lands.
	f.svc.applyReport(f.host.ID, ready("169.254.11.2"))
	vm, err = f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.77", vm.NetworkIP, "an unusable address is dropped, not stored")

	f.svc.applyReport(f.host.ID, ready("192.168.0.99"))
	vm, err = f.st.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.99", vm.NetworkIP, "a good address after junk still lands")
}

// TestApplyReportRoutesLeasesThroughTheDedupCache pins the half of the skip
// that lives in applyReport: every reported address goes through netIPTrack,
// keyed by VM. A bridged guest re-reports the same lease every tick forever and
// the store runs on one connection, so a direct RecordVMNetworkIP here would
// spend a round trip per bridged guest per tick for the life of the fleet — and
// leave no trace, because the write succeeds every time.
//
// The skip itself — repeat writes nothing, change writes — belongs to
// TestNetTrackerWritesOnceThenStaysQuiet, which counts writes. This test can
// only see the cache, so it asserts what the cache proves: that applyReport
// consults it, with the reported address under the reporting VM's id.
func TestApplyReportRoutesLeasesThroughTheDedupCache(t *testing.T) {
	f := setup(t)
	networkedVM(t, f, "vm1")
	report := func(ip string) *pb.Report {
		return &pb.Report{Vms: []*pb.VMStatus{{
			VmId: "vm1", PowerState: "running", Phase: "ready", Ip: "10.77.1.2", NetworkIp: ip,
		}}}
	}

	f.svc.applyReport(f.host.ID, report("192.168.0.42"))
	f.svc.applyReport(f.host.ID, report("192.168.0.42"))
	f.svc.applyReport(f.host.ID, report("192.168.0.42"))
	assertLeaseCache(t, f, map[string]string{"vm1": "192.168.0.42"},
		"a lease that never reached the cache is a lease re-written every tick forever")

	// A renewal to a different address must land in the cache, or the guest's
	// new lease is remembered as the old one and never written again.
	f.svc.applyReport(f.host.ID, report("192.168.0.99"))
	assertLeaseCache(t, f, map[string]string{"vm1": "192.168.0.99"},
		"the cache must hold the newest reported lease, not the first one seen")
}

// assertLeaseCache reads netIPTrack under its own lock and compares it to want.
func assertLeaseCache(t *testing.T, f *fixture, want map[string]string, msg string) {
	t.Helper()
	f.svc.netIPTrack.mu.Lock()
	defer f.svc.netIPTrack.mu.Unlock()
	assert.Equal(t, want, f.svc.netIPTrack.last, msg)
}

// TestApplyReportForgetsAReapedVMsLease bounds the cache: a VM whose row is
// hard-deleted must not leave its address remembered forever.
func TestApplyReportForgetsAReapedVMsLease(t *testing.T) {
	f := setup(t)
	networkedVM(t, f, "vm1")

	f.svc.applyReport(f.host.ID, &pb.Report{
		Vms: []*pb.VMStatus{{VmId: "vm1", PowerState: "running", Phase: "ready", NetworkIp: "192.168.0.42"}},
	})
	require.NoError(t, f.st.TombstoneVM("vm1"))
	f.svc.applyReport(f.host.ID, &pb.Report{Destroyed: []string{"vm1"}})

	f.svc.netIPTrack.mu.Lock()
	defer f.svc.netIPTrack.mu.Unlock()
	assert.Empty(t, f.svc.netIPTrack.last, "a reaped VM's address is dead weight")
}