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: ®istry.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)
}