a73x

internal/server/api/abandoned_vm_test.go

Ref:   Size: 3.9 KiB   History

package api

import (
	"database/sql"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

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

// tombstoneOneVM enrolls a host, creates a VM on it, and DELETEs (tombstones)
// it, returning the vm id. The host never reports, so its agent is offline in
// the registry — the ack that would normally hard-delete the row never comes.
func tombstoneOneVM(t *testing.T, ts *httptest.Server, st *store.Store) string {
	t.Helper()
	out := enroll(t, ts)
	hostID := out["host_id"]
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": "vm-a"})
	require.Equal(t, http.StatusCreated, resp.StatusCode)

	vms, err := st.ListVMs()
	require.NoError(t, err)
	require.Len(t, vms, 1)
	id := vms[0].ID

	resp = do(t, "DELETE", ts.URL+"/api/v1/vms/"+id, testPAT, nil)
	require.Equal(t, http.StatusNoContent, resp.StatusCode)

	got, err := st.GetVM(id)
	require.NoError(t, err, "tombstoned row must still exist (no agent acked it)")
	require.NotNil(t, got.DeletedAt, "row is tombstoned")
	return id
}

// TestAbandonedTombstoneOnOfflineHostIsReaped pins the failed-VM-delete fix: a
// VM deleted while its host's agent is offline (never acks the destroy) would
// otherwise linger forever. Past the grace, the server-side sweep hard-deletes
// it so the console row clears.
func TestAbandonedTombstoneOnOfflineHostIsReaped(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	id := tombstoneOneVM(t, ts, st)

	// Sixteen minutes on, host still offline (never reported). The clock offsets
	// here are literals, not multiples of abandonedVMReapGrace: a sweep driven
	// by the constant it is testing reaps on schedule at any grace, including
	// the millisecond that would force-delete a VM whose agent is mid-restart.
	assert.True(t, a.sweepAbandonedVMs(time.Now().Add(16*time.Minute)),
		"a VM tombstoned sixteen minutes ago on an offline host must be reaped: past the grace nobody is coming to ack the "+
			"destroy, and the row sits in the operator's console forever")

	_, err := st.GetVM(id)
	assert.True(t, errors.Is(err, sql.ErrNoRows), "the zombie row must be gone after the sweep")
}

// TestFreshTombstoneIsNotReaped guards the grace: a just-deleted VM must be
// left for the agent to ack normally — a transient host outage (agent restart,
// redeploy) must not trip an immediate server-side force-delete.
func TestFreshTombstoneIsNotReaped(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	id := tombstoneOneVM(t, ts, st)

	assert.False(t, a.sweepAbandonedVMs(time.Now()),
		"a VM tombstoned within the grace must not be force-deleted")
	assert.False(t, a.sweepAbandonedVMs(time.Now().Add(14*time.Minute)),
		"and the grace must still be running fourteen minutes in: it is the window an agent has to come back and ack its "+
			"own destroy — an agent restart or a host redeploy takes minutes, and a grace shorter than that turns every "+
			"transient outage into a server-side force-delete of a VM the agent was about to tear down properly")

	_, err := st.GetVM(id)
	assert.NoError(t, err, "row must survive a within-grace sweep")
}

// TestOnlineHostTombstoneIsNotReaped guards the offline gate: while the host's
// agent is live it will ack the destroy through the normal quarantine→destroy
// path, so the server must not race it — even for an old tombstone.
func TestOnlineHostTombstoneIsNotReaped(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	id := tombstoneOneVM(t, ts, st)

	// Make the host online: a fresh report sets LastSeen to now.
	vms, err := st.ListVMs()
	require.NoError(t, err)
	a.reg.UpdateReport(vms[0].HostID, registry.Report{})

	assert.False(t, a.sweepAbandonedVMs(time.Now().Add(16*time.Minute)),
		"a live agent owns the reap; the server must not force-delete")

	_, err = st.GetVM(id)
	assert.NoError(t, err, "row must survive while the host is online")
}