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