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