a73x

internal/server/api/narration_test.go

Ref:   Size: 9.6 KiB   History

package api

import (
	"encoding/json"
	"testing"

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

// vmByID pulls one VM out of the list response.
func vmByID(t *testing.T, ts string, id string) map[string]any {
	t.Helper()
	resp := do(t, "GET", ts+"/api/v1/vms", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var list []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
	for _, vm := range list {
		if vm["id"] == id {
			return vm
		}
	}
	t.Fatalf("vm %s not in the listing", id)
	return nil
}

// TestWhatAHostIsDoingReachesTheWire is the outbound leg of the narration: the
// sentence the host reported is served beside the phase, so the console can put
// it under a VM that would otherwise say only "creating".
func TestWhatAHostIsDoingReachesTheWire(t *testing.T) {
	ts, _, _, reg, _ := newServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	reg.UpdateReport(host["host_id"], registry.Report{
		VMs: []registry.VMStatus{{
			VMID: vmID, PowerState: "stopped", Phase: "creating",
			StatusDetail: "downloading image 1.2/3.7 GiB",
		}},
	})

	vm := vmByID(t, ts.URL, vmID)
	assert.Equal(t, "creating", vm["phase"])
	assert.Equal(t, "downloading image 1.2/3.7 GiB", vm["status_detail"])
}

// TestAVMOnASilentAgentReadsAsItAlwaysDid is the fielded-agent case end to end:
// a host that reports no detail — an agent older than the field — serves an
// empty one, and the console renders exactly what it rendered before.
func TestAVMOnASilentAgentReadsAsItAlwaysDid(t *testing.T) {
	ts, _, _, reg, _ := newServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	reg.UpdateReport(host["host_id"], registry.Report{
		VMs: []registry.VMStatus{{VMID: vmID, PowerState: "stopped", Phase: "creating"}},
	})

	vm := vmByID(t, ts.URL, vmID)
	assert.Equal(t, "creating", vm["phase"])
	assert.Equal(t, "", vm["status_detail"])
}

// TestExposureSessionCountersReachTheWire pins the counters' outbound leg: what
// the host counted is what the exposure row serves.
func TestExposureSessionCountersReachTheWire(t *testing.T) {
	ts, _, _, reg, _ := newServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 8080, "host_port": 30080})
	require.Equal(t, 201, resp.StatusCode)
	var created map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))

	reg.UpdateReport(host["host_id"], registry.Report{
		Exposures: []registry.ExposureStatus{{
			ID: created["id"].(string), State: "active",
			Sessions: &registry.ExposureSessions{Active: 7, Refused: 12, Dropped: 3},
		}},
	})

	resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var list []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
	require.Len(t, list, 1)

	sessions, ok := list[0]["sessions"].(map[string]any)
	require.True(t, ok, "a counted exposure carries its counters")
	assert.Equal(t, float64(7), sessions["active"])
	assert.Equal(t, float64(12), sessions["refused"])
	assert.Equal(t, float64(3), sessions["dropped"])
}

// TestAnUncountedExposureServesNoCounters is the case that decides the console's
// display rule. Two exposures nobody counted: one on an agent too old to count
// (reported, no counters), one nothing has reported on at all. Both serve null
// rather than zeros, because a row of zeros is a claim — "this port has turned
// nobody away" — that nothing on the wire supports.
func TestAnUncountedExposureServesNoCounters(t *testing.T) {
	ts, _, _, reg, _ := newServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	newExposure := func(guestPort, hostPort int64) string {
		t.Helper()
		resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
			map[string]any{"guest_port": guestPort, "host_port": hostPort})
		require.Equal(t, 201, resp.StatusCode)
		var e map[string]any
		require.NoError(t, json.NewDecoder(resp.Body).Decode(&e))
		return e["id"].(string)
	}
	reported := newExposure(8080, 30080)
	newExposure(9090, 30081) // never reported on

	reg.UpdateReport(host["host_id"], registry.Report{
		Exposures: []registry.ExposureStatus{{ID: reported, State: "active"}},
	})

	resp := do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var list []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
	require.Len(t, list, 2)

	assert.Equal(t, "active", list[0]["state"])
	assert.Nil(t, list[0]["sessions"], "an agent that counts nothing reports nothing")
	assert.Equal(t, "pending", list[1]["state"])
	assert.Nil(t, list[1]["sessions"], "an unreported exposure has no counters either")
}

// TestAVMOnADarkHostReadsAsUnreachable is the whole point of the lifecycle
// projection: the durable row is the last thing a host said, and once that host
// stops saying anything the row is a memory, not a status. A VM the plane
// recorded as running keeps that power_state forever — nothing rewrites it when
// the agent goes away — so a read that trusted it would tell an operator their
// guest is up while the machine under it is off.
//
// The registry has no entry at all for this host (it enrolled, it never
// reported), which is the shape an operator hits by placing a VM on the wrong
// host: the create is accepted as desired state, and then nothing happens.
// "unreachable" is what the plane actually knows.
func TestAVMOnADarkHostReadsAsUnreachable(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	// Drive the durable row to the state a host that HAD been reporting would
	// have left behind: a settled, running guest. Nothing reports it now.
	_, err := st.RecordVMStatus(vmID, host["host_id"], "ready", "", "10.77.1.5")
	require.NoError(t, err)
	require.NoError(t, st.SetVMPower(vmID, "running"))

	vm := vmByID(t, ts.URL, vmID)
	assert.Equal(t, "unreachable", vm["lifecycle"],
		"a host that says nothing cannot vouch for its guests")
	assert.Equal(t, "", vm["actual_power"], "nothing observed this VM's power")
	assert.Equal(t, "", vm["phase"], "nothing observed this VM's phase")
}

// TestADarkHostsVMRecoversItsLifecycleWhenTheHostComesBack is the other half:
// unreachable is a read-time projection over live registry state, not a state
// the VM was moved into, so the VM needs no repair when its host reconnects —
// the very first report puts every word back.
func TestADarkHostsVMRecoversItsLifecycleWhenTheHostComesBack(t *testing.T) {
	ts, st, _, reg, _ := newServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")
	_, err := st.RecordVMStatus(vmID, host["host_id"], "ready", "", "10.77.1.5")
	require.NoError(t, err)
	require.NoError(t, st.SetVMPower(vmID, "running"))

	require.Equal(t, "unreachable", vmByID(t, ts.URL, vmID)["lifecycle"])

	reg.UpdateReport(host["host_id"], registry.Report{
		VMs: []registry.VMStatus{{VMID: vmID, PowerState: "running", Phase: "ready"}},
	})

	vm := vmByID(t, ts.URL, vmID)
	assert.Equal(t, "ready", vm["lifecycle"], "one report is the whole repair")
	assert.Equal(t, "running", vm["actual_power"])
}

// TestAStaleHostsObservationsAreNotServed covers the second way a host goes
// dark, which the response has to treat identically to the first: the registry
// still HOLDS an entry — the host connected and reported, then went quiet past
// the online window — so every observation it last made is still sitting there,
// ready to be served as though it were current.
//
// It is asserted against buildVMResponses directly because the distinction is
// registry-clock-dependent (a real host would have to be left alone for
// OnlineWindow), and this is the merge that decides it.
func TestAStaleHostsObservationsAreNotServed(t *testing.T) {
	vm := store.VM{ID: "vm1", HostID: "h1", Status: "ready", PowerState: "running"}
	// Everything a host that WAS reporting leaves behind, with Online false —
	// exactly what registry.Get derives once the last report ages out.
	stale := regState{ok: true, st: registry.HostState{
		Online: false,
		Report: registry.Report{VMs: []registry.VMStatus{{
			VMID: "vm1", PowerState: "running", Phase: "ready",
			StatusDetail: "booting",
		}}},
	}}

	out := (&API{}).buildVMResponses([]store.VM{vm}, map[string]regState{"h1": stale})
	require.Len(t, out, 1)
	assert.Equal(t, "unreachable", out[0].Lifecycle)
	assert.Empty(t, out[0].ActualPower, "a host that stopped reporting stopped observing")
	assert.Empty(t, out[0].Phase, "the last phase it saw is a memory, not a status")
	assert.Empty(t, out[0].StatusDetail, "and so is the last thing it said it was doing")
}

// TestAnOnlineHostsObservationsStillReachTheWire is the control for the test
// above: the gate must be the host's silence and nothing else.
func TestAnOnlineHostsObservationsStillReachTheWire(t *testing.T) {
	vm := store.VM{ID: "vm1", HostID: "h1", Status: "ready", PowerState: "running"}
	live := regState{ok: true, st: registry.HostState{
		Online: true,
		Report: registry.Report{VMs: []registry.VMStatus{{
			VMID: "vm1", PowerState: "running", Phase: "ready",
			StatusDetail: "booting",
		}}},
	}}

	out := (&API{}).buildVMResponses([]store.VM{vm}, map[string]regState{"h1": live})
	require.Len(t, out, 1)
	assert.Equal(t, "ready", out[0].Lifecycle)
	assert.Equal(t, "running", out[0].ActualPower)
	assert.Equal(t, "ready", out[0].Phase)
	assert.Equal(t, "booting", out[0].StatusDetail)
}