a73x

internal/server/api/decommission_api_test.go

Ref:   Size: 4.8 KiB   History

package api

import (
	"bufio"
	"context"
	"log/slog"
	"net/http"
	"strings"
	"testing"
	"time"

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

func TestDecommissionEndpointThenSweepRemovesDrainedHost(t *testing.T) {
	ts, _, _, _, a := newServer(t)
	out := enroll(t, ts)
	hostID := out["host_id"]

	// Decommission a host with no VMs.
	resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil)
	require.Equal(t, http.StatusAccepted, resp.StatusCode)

	// It now reports decommissioning.
	hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
	require.Len(t, hosts, 1)
	assert.Equal(t, "decommissioning", hosts[0]["status"])

	// The sweeper finalizes it (no VMs => drained).
	assert.True(t, a.sweepDecommissioned(), "sweep should remove the drained host")
	hosts = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
	assert.Empty(t, hosts, "host should be gone after sweep")
}

func TestDecommissionUnknownHostIs404(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/nope", testPAT, nil)
	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}

func TestSweepLeavesHostWithVMs(t *testing.T) {
	ts, _, _, _, a := newServer(t)
	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)

	do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil)
	// VM row still present (not yet reaped) => sweep must not remove the host.
	assert.False(t, a.sweepDecommissioned(), "host with VM rows must not be swept")
	hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
	require.Len(t, hosts, 1)
}

// A decommission blocked on volumes never finishes on its own, so the sweep
// has to say so — once, not thirty times a minute.
func TestSweepSaysWhenAHostIsStuckOnVolumes(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	out := enroll(t, ts)
	hostID := out["host_id"]

	c, err := st.CreateVolumeClaim(testTenant, "data", 5)
	require.NoError(t, err)
	// Straight to the store: no HTTP surface names a claim yet.
	require.NoError(t, st.CreateVM(store.VM{
		ID: "vm-a", HostID: hostID, Name: "vm-a", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 1, PowerState: "running",
		VolumeClaimIDs: []string{c.ID},
	}))

	do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil)

	var logged strings.Builder
	restore := slog.Default()
	slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
	t.Cleanup(func() { slog.SetDefault(restore) })

	// The VM is still there, so the first refusals are the ordinary undrained
	// kind and stay quiet.
	assert.False(t, a.sweepDecommissioned())
	assert.Empty(t, logged.String(), "an undrained VM is a decommission in progress, not a stuck one")

	// Reap the VM the decommission already tombstoned: now only the volume is
	// in the way, and that one is terminal.
	require.NoError(t, st.HardDeleteVM("vm-a", hostID))
	assert.False(t, a.sweepDecommissioned(), "a host holding volumes must not be swept")
	first := logged.String()
	assert.Contains(t, first, "decommission is stuck")
	assert.Contains(t, first, "volumes=1")

	// Every later pass finds the same count and stays silent.
	assert.False(t, a.sweepDecommissioned())
	assert.False(t, a.sweepDecommissioned())
	assert.Equal(t, first, logged.String(), "the stuck line is said once, not once per tick")

	hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
	require.Len(t, hosts, 1, "the host stays until its claims are deleted")
}

func TestEventsStreamSendsSnapshot(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	enroll(t, ts)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/v1/events?ticket="+mintTicket(t, ts.URL, testPAT), nil)
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	require.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Contains(t, resp.Header.Get("Content-Type"), "text/event-stream")

	// Read the initial event: must be a state event whose data has hosts + vms.
	sc := bufio.NewScanner(resp.Body)
	var sawState, sawData bool
	for sc.Scan() {
		line := sc.Text()
		if line == "event: state" {
			sawState = true
		}
		if strings.HasPrefix(line, "data: ") {
			sawData = true
			assert.Contains(t, line, `"hosts"`)
			assert.Contains(t, line, `"vms"`)
			break
		}
	}
	assert.True(t, sawState, "should receive a state event")
	assert.True(t, sawData, "should receive snapshot data")
}

func TestEventsRejectsBadToken(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	resp := do(t, "GET", ts.URL+"/api/v1/events?ticket=wrong", "", nil)
	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}