a73x

internal/server/api/decommission_poke_test.go

Ref:   Size: 4.2 KiB   History

package api

import (
	"encoding/json"
	"net/http"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestDecommissionPokesAgent pins the fix for the decommission stall: the
// handler must wake the host's desired-state stream (like every VM mutation
// handler does), otherwise an online host never learns its VMs were tombstoned
// and stalls in `decommissioning` forever.
func TestDecommissionPokesAgent(t *testing.T) {
	ts, _, _, _, a := newServer(t)
	out := enroll(t, ts)
	hostID := out["host_id"]

	ch, cancel := a.hub.Subscribe(hostID)
	defer cancel()

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

	select {
	case <-ch:
	case <-time.After(2 * time.Second):
		t.Fatal("decommission must poke the host's desired-state stream")
	}
}

// TestForceDecommissionRemovesHostWithVMs pins the dead-hardware escape hatch:
// ?force=true purges VM rows and removes the host immediately, without waiting
// for an agent drain that (for dead hardware) can never happen.
func TestForceDecommissionRemovesHostWithVMs(t *testing.T) {
	ts, _, _, _, _ := 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)

	// Graceful delete would only tombstone and wait; force removes now.
	resp = do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", testPAT, nil)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
	assert.Empty(t, hosts, "force must remove the host immediately")
}

// TestForceDecommissionAuditNamesWhatItDestroyed pins the receipt for the one
// path that loses data on purpose. The volumes are deleted with the host and
// their ids exist nowhere afterwards, so this audit row is the only record an
// operator reconciling a lost host against backups has to work from.
func TestForceDecommissionAuditNamesWhatItDestroyed(t *testing.T) {
	ts, st, _, reg, _ := newServer(t)
	floorVolumes(t, "v0.0.7")
	out := enroll(t, ts)
	hostID := out["host_id"]
	onlineAt(reg, hostID, "v0.0.7")

	resp := do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT,
		map[string]any{"name": "payroll", "size_gb": 5})
	require.Equal(t, http.StatusCreated, resp.StatusCode)
	var claim struct {
		ID string `json:"id"`
	}
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&claim))
	require.NotEmpty(t, claim.ID)
	require.Equal(t, http.StatusCreated, do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": hostID, "name": "vm-a", "volume_claims": []string{claim.ID}}).StatusCode)

	// The volume id as minted, read while the row still exists.
	vols, err := st.ListVolumesForHost(hostID)
	require.NoError(t, err)
	require.Len(t, vols, 1)

	require.Equal(t, http.StatusOK,
		do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", testPAT, nil).StatusCode)

	rows, err := st.ListAudit(testTenant, 10)
	require.NoError(t, err)
	require.NotEmpty(t, rows)
	require.Equal(t, "host.decommission", rows[0].Action, "newest first")
	var detail map[string]string
	require.NoError(t, json.Unmarshal([]byte(rows[0].Detail), &detail))
	assert.Equal(t, "true", detail["force"])
	assert.Equal(t, "1", detail["vms_purged"])
	assert.Equal(t, "1", detail["volumes_destroyed"])
	assert.Equal(t, "1", detail["claims_unbound"])
	assert.Equal(t, vols[0].ID, detail["volume_ids"], "the destroyed volume, named")
}

// TestCreateVMRejectedOnDecommissioningHost pins that a host mid-decommission
// no longer accepts new VMs (previously only the FK was enforced, so a create
// could land on a host being torn down).
func TestCreateVMRejectedOnDecommissioningHost(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	out := enroll(t, ts)
	hostID := out["host_id"]

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

	resp = do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": "vm-late"})
	assert.Equal(t, http.StatusConflict, resp.StatusCode, "create on a decommissioning host must be rejected")
}