a73x

internal/server/store/exposures_test.go

Ref:   Size: 10.4 KiB   History

package store

import (
	"database/sql"
	"strconv"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// makeExposureVM enrolls nothing new: it places one live VM on h so an exposure
// has something to belong to.
func makeExposureVM(t *testing.T, s *Store, h Host, name string) VM {
	t.Helper()
	return vmWithResources(t, s, h, name, 1, 512, 5)
}

func TestCreateExposureAllocatesFromTheReservedRange(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)
	assert.Equal(t, vm.ID, e.VMID)
	assert.Equal(t, h.ID, e.HostID)
	assert.Equal(t, h.Tenant, e.Tenant, "an exposure's tenant is always its VM's")
	assert.Equal(t, int64(8080), e.GuestPort)
	assert.Equal(t, int64(MinAllocatedHostPort), e.HostPort)
	assert.Equal(t, "tcp", e.Protocol)
	assert.Equal(t, "lan", e.Scope)
	assert.NotEmpty(t, e.ID)
}

// TestReservedRangeSitsWhereAHostCanBind pins the two numbers the reserved
// range is built from, against literals rather than against themselves: every
// other exposure test measures ports relative to MinAllocatedHostPort, so the
// base is free to move anywhere and the suite stays green.
func TestReservedRangeSitsWhereAHostCanBind(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)

	assert.GreaterOrEqual(t, e.HostPort, int64(1024),
		"an auto-allocated host port must land above the privileged range: below 1024 the agent's bind needs root and "+
			"lands on ports the host's own sshd, DNS or web server already own, so eitri would hand a tenant's guest the "+
			"host's front door and the bind would fail — or worse, succeed first")
	assert.LessOrEqual(t, e.HostPort, int64(32767),
		"and below 32768, where Linux's default ip_local_port_range begins: a reserved port inside the kernel's ephemeral "+
			"range is one the kernel will also hand to an outbound socket, so the exposure's listener fails to bind "+
			"intermittently, on a schedule nobody can reproduce")
	assert.LessOrEqual(t, int64(MaxAllocatedHostPort), int64(32767),
		"the top of the reserved range answers to the same kernel: 30000-32767 is the documented span (docs/mcp.md), and "+
			"it stops one short of the ephemeral floor")
}

func TestCreateExposureAllocatesTheLowestFreePort(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	first, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)
	second, err := s.CreateExposure(vm.ID, 8081, 0, "tcp")
	require.NoError(t, err)
	assert.Equal(t, int64(MinAllocatedHostPort), first.HostPort)
	assert.Equal(t, int64(MinAllocatedHostPort+1), second.HostPort)

	// A freed port is the lowest gap, so it is handed out again.
	require.NoError(t, s.DeleteExposure(first.ID))
	third, err := s.CreateExposure(vm.ID, 8082, 0, "tcp")
	require.NoError(t, err)
	assert.Equal(t, int64(MinAllocatedHostPort), third.HostPort)
}

func TestCreateExposureHonoursARequestedPort(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	e, err := s.CreateExposure(vm.ID, 8080, 8443, "tcp")
	require.NoError(t, err)
	assert.Equal(t, int64(8443), e.HostPort)
}

func TestCreateExposureRefusesAPortAnotherExposureHolds(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")
	other := makeExposureVM(t, s, h, "web-2")

	_, err := s.CreateExposure(vm.ID, 8080, 8443, "tcp")
	require.NoError(t, err)

	_, err = s.CreateExposure(other.ID, 9090, 8443, "tcp")
	assert.ErrorIs(t, err, ErrHostPortTaken, "the unique index IS the collision check")
}

func TestCreateExposureAllowsTheSamePortInTheOtherProtocol(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	_, err := s.CreateExposure(vm.ID, 8080, 8443, "tcp")
	require.NoError(t, err)

	udp, err := s.CreateExposure(vm.ID, 8080, 8443, "udp")
	require.NoError(t, err, "a host port is claimed per protocol")
	assert.Equal(t, "udp", udp.Protocol)

	_, err = s.CreateExposure(vm.ID, 9090, 8443, "udp")
	assert.ErrorIs(t, err, ErrHostPortTaken, "the second UDP claim on that port is a collision")
}

func TestCreateExposureAllowsTheSamePortOnAnotherHost(t *testing.T) {
	s := newStore(t)
	h1 := enrollHost(t, s)
	tok, err := s.CreateEnrollmentToken(testTenant)
	require.NoError(t, err)
	h2, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "h2", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	require.NoError(t, err)

	vm1 := makeExposureVM(t, s, h1, "web-1")
	vm2 := makeExposureVM(t, s, h2, "web-2")

	_, err = s.CreateExposure(vm1.ID, 8080, 8443, "tcp")
	require.NoError(t, err)
	_, err = s.CreateExposure(vm2.ID, 8080, 8443, "tcp")
	assert.NoError(t, err, "the port is unique per host, not per fleet")
}

func TestCreateExposureRefusesAnUnknownOrTombstonedVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	_, err := s.CreateExposure("no-such-vm", 8080, 0, "tcp")
	assert.ErrorIs(t, err, ErrExposureVMNotFound)

	require.NoError(t, s.TombstoneVM(vm.ID))
	_, err = s.CreateExposure(vm.ID, 8080, 0, "tcp")
	assert.ErrorIs(t, err, ErrExposureVMNotFound, "a VM being torn down takes no new exposures")
}

func TestCreateExposureReportsRangeExhaustion(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	// Fill the whole reserved range by hand — CreateExposure would be 2768
	// transactions.
	for p := int64(MinAllocatedHostPort); p <= MaxAllocatedHostPort; p++ {
		_, err := s.db.Exec(
			`INSERT INTO exposures(id, tenant, vm_id, host_id, guest_port, host_port, protocol, scope, created_at)
			 VALUES (?,?,?,?,?,?,'tcp','lan','2026-08-05T00:00:00Z')`,
			"fill-"+strconv.FormatInt(p, 10), h.Tenant, vm.ID, h.ID, 8080, p)
		require.NoError(t, err)
	}

	_, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	assert.ErrorIs(t, err, ErrNoFreeHostPort)
}

func TestCreateExposureBumpsTheEpoch(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	before, err := s.Epoch()
	require.NoError(t, err)
	_, err = s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)
	after, err := s.Epoch()
	require.NoError(t, err)
	assert.Greater(t, after, before, "exposures are desired state; agents must re-snapshot")
}

func TestCreateExposureCountsANamedPortInTheReservedRange(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	named, err := s.CreateExposure(vm.ID, 8080, MinAllocatedHostPort, "tcp")
	require.NoError(t, err)
	assert.Equal(t, int64(MinAllocatedHostPort), named.HostPort)

	auto, err := s.CreateExposure(vm.ID, 8081, 0, "tcp")
	require.NoError(t, err)
	assert.Equal(t, int64(MinAllocatedHostPort+1), auto.HostPort, "a named port inside the reserved range is held against auto-allocation")
}

func TestAllocationSkipsAPortHeldByEitherProtocol(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	_, err := s.CreateExposure(vm.ID, 8080, MinAllocatedHostPort, "udp")
	require.NoError(t, err)

	// The allocator hands out a number a caller can name without a protocol
	// beside it, so the UDP claim takes the port out of the running for TCP too.
	auto, err := s.CreateExposure(vm.ID, 8081, 0, "tcp")
	require.NoError(t, err)
	assert.Equal(t, int64(MinAllocatedHostPort+1), auto.HostPort)
}

func TestListExposuresForVMIsPortOrdered(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")
	other := makeExposureVM(t, s, h, "web-2")

	_, err := s.CreateExposure(vm.ID, 8081, 31000, "tcp")
	require.NoError(t, err)
	_, err = s.CreateExposure(vm.ID, 8080, 30500, "tcp")
	require.NoError(t, err)
	_, err = s.CreateExposure(other.ID, 9090, 30001, "tcp")
	require.NoError(t, err)

	got, err := s.ListExposuresForVM(vm.ID)
	require.NoError(t, err)
	require.Len(t, got, 2, "another VM's exposures are not this VM's")
	assert.Equal(t, int64(30500), got[0].HostPort)
	assert.Equal(t, int64(31000), got[1].HostPort)
}

func TestListExposuresForHostSkipsTombstonedVMs(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	live := makeExposureVM(t, s, h, "web-1")
	dying := makeExposureVM(t, s, h, "web-2")

	_, err := s.CreateExposure(live.ID, 8080, 30001, "tcp")
	require.NoError(t, err)
	_, err = s.CreateExposure(dying.ID, 8080, 30002, "tcp")
	require.NoError(t, err)

	require.NoError(t, s.TombstoneVM(dying.ID))

	got, err := s.ListExposuresForHost(h.ID)
	require.NoError(t, err)
	require.Len(t, got, 1, "no listener outlives the thing it points at")
	assert.Equal(t, live.ID, got[0].VMID)
}

func TestGetExposureAndDelete(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")

	e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)

	got, err := s.GetExposure(e.ID)
	require.NoError(t, err)
	assert.Equal(t, e.HostPort, got.HostPort)
	assert.Equal(t, e.VMID, got.VMID)

	require.NoError(t, s.DeleteExposure(e.ID))
	_, err = s.GetExposure(e.ID)
	assert.ErrorIs(t, err, sql.ErrNoRows)

	// Deleting one that is already gone is not a silent success.
	assert.ErrorIs(t, s.DeleteExposure(e.ID), sql.ErrNoRows)
}

func TestDeleteExposureBumpsTheEpoch(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")
	e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)

	before, err := s.Epoch()
	require.NoError(t, err)
	require.NoError(t, s.DeleteExposure(e.ID))
	after, err := s.Epoch()
	require.NoError(t, err)
	assert.Greater(t, after, before)
}

func TestReapingAVMDestroysItsExposures(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")
	e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)

	require.NoError(t, s.TombstoneVM(vm.ID))
	require.NoError(t, s.HardDeleteVM(vm.ID, h.ID))

	_, err = s.GetExposure(e.ID)
	assert.ErrorIs(t, err, sql.ErrNoRows, "an exposure belongs to its VM and goes with it")
}

func TestForceRemovingAHostDestroysItsExposures(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeExposureVM(t, s, h, "web-1")
	e, err := s.CreateExposure(vm.ID, 8080, 0, "tcp")
	require.NoError(t, err)

	gone, err := s.ForceRemoveHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, 1, gone.VMsPurged)

	_, err = s.GetExposure(e.ID)
	assert.ErrorIs(t, err, sql.ErrNoRows)
}