a73x

internal/server/store/decommission_test.go

Ref:   Size: 4.5 KiB   History

package store

import (
	"testing"

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

func makeVM(t *testing.T, s *Store, host Host, name string) VM {
	t.Helper()
	vm := VM{
		ID: random.Hex(8), HostID: host.ID, Name: name,
		ImageURL: "http://img", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}
	require.NoError(t, s.CreateVM(vm))
	return vm
}

func TestDecommissionHostTombstonesVMsAndSetsStatus(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	makeVM(t, s, h, "vm-a")
	makeVM(t, s, h, "vm-b")

	epochBefore, _ := s.Epoch()
	require.NoError(t, s.DecommissionHost(h.ID))

	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "decommissioning", got.Status)

	// All the host's VMs are tombstoned (no longer live).
	vms, err := s.ListVMs()
	require.NoError(t, err)
	for _, vm := range vms {
		if vm.HostID == h.ID {
			assert.NotNil(t, vm.DeletedAt, "vm %s should be tombstoned", vm.Name)
		}
	}

	epochAfter, _ := s.Epoch()
	assert.Greater(t, epochAfter, epochBefore, "decommission must bump epoch")
}

// TestRemoveHostDoesNotReissueItsSubnet pins the reversal of what this test
// used to assert. A departing host's bridge_cidr is NOT returned to the pool,
// because post-inversion that column holds what the HOST claimed — so
// re-issuing it would hand one host's subnet to the next as a suggestion, and a
// Mac's 192.168.64.0/24 is not something to advise a Linux host to build.
//
// The monotonic index alone is sufficient: a /16 pool is 65k /24s, and leaking
// indices across host churn is cheaper than re-issuing a value the fleet never
// chose.
func TestRemoveHostDoesNotReissueItsSubnet(t *testing.T) {
	s := newStore(t)
	h1 := enrollHost(t, s)
	assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)
	tok2, _ := s.CreateEnrollmentToken(testTenant)
	h2, _ := s.RedeemEnrollmentToken(tok2, EnrollFacts{Name: "b", OS: "linux", Arch: "amd64", Provisioner: "cloudhv"})
	assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)

	vm := makeVM(t, s, h1, "vm-a")
	require.NoError(t, s.DecommissionHost(h1.ID))
	require.NoError(t, s.HardDeleteVM(vm.ID, h1.ID))
	require.NoError(t, s.RemoveHost(h1.ID))

	_, err := s.GetHost(h1.ID)
	assert.Error(t, err, "removed host should be gone")

	tok3, _ := s.CreateEnrollmentToken(testTenant)
	h3, err := s.RedeemEnrollmentToken(tok3, EnrollFacts{Name: "c", OS: "linux", Arch: "amd64", Provisioner: "cloudhv"})
	require.NoError(t, err)
	assert.Equal(t, "10.77.3.0/24", h3.BridgeCIDR,
		"the allocator moves on; a departed host's subnet is never re-issued")
}

func TestRemoveHostRefusesWhileVMsRemain(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	makeVM(t, s, h, "vm-a")
	require.NoError(t, s.DecommissionHost(h.ID))
	// VM row still present (not yet reaped) — removal must refuse.
	err := s.RemoveHost(h.ID)
	// The curated message, not a bare error: the vms.host_id foreign key would
	// also refuse, so an error alone proves nothing about the drain check. The
	// operator sees this text in the console, and a schema edit that turned the
	// FK into ON DELETE CASCADE would silently take the VM rows with the host.
	assert.ErrorContains(t, err, "not drained", "RemoveHost must refuse by counting VM rows, with the message the console shows the operator")
}

// TestRemoveHostDrainSequence pins the same VM-row-counting behavior the
// deleted HostVMCount used to expose directly, but through RemoveHost (its
// only production caller): decommission refuses to finalize while a VM row
// remains — live or merely tombstoned-but-not-yet-reaped — and succeeds the
// instant the row is hard-deleted.
func TestRemoveHostDrainSequence(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.DecommissionHost(h.ID))
	require.NoError(t, s.RemoveHost(h.ID), "no VMs: RemoveHost should succeed immediately")

	s2 := newStore(t)
	h2 := enrollHost(t, s2)
	vm := makeVM(t, s2, h2, "vm-a")
	assert.ErrorContains(t, s2.RemoveHost(h2.ID), "not drained", "live VM row: RemoveHost must refuse by its own count, not by the foreign key underneath it")

	// DecommissionHost tombstones the host's VMs but leaves the rows in place
	// until the agent acks their destroy — still not drained.
	require.NoError(t, s2.DecommissionHost(h2.ID))
	assert.ErrorContains(t, s2.RemoveHost(h2.ID), "not drained", "tombstoned but not reaped: a row the agent has not acked still counts")

	require.NoError(t, s2.HardDeleteVM(vm.ID, h2.ID))
	require.NoError(t, s2.RemoveHost(h2.ID), "reaped: RemoveHost should now succeed")
}