Merge remote-tracking branch 'origin/main' into feat/iso-installer

This commit is contained in:
joshsiv-mulga
2026-04-28 14:47:33 +10:00
29 changed files with 1527 additions and 1510 deletions
+20 -1
View File
@@ -32,6 +32,7 @@ jobs:
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 17, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-pseudo, extra: "tofu-examples" }
- { cell: 18, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "reboot-single" }
runs-on: [ self-hosted, "${{ matrix.runner }}" ]
timeout-minutes: 35
@@ -138,7 +139,7 @@ jobs:
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run Single Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra != 'tofu-examples'
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
@@ -177,6 +178,24 @@ jobs:
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"cd ~/e2e-tests && ./run-tofu-examples-e2e.sh"
- name: Run Reboot Resilience E2E
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'reboot-single'
working-directory: mulga/scripts/tofu-cluster
# Driver runs from the runner (NOT via ssh-and-run) so it survives the
# systemctl-reboot-induced ssh disconnect mid-run.
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
REBOOT_DIR="${RUNNER_TEMP}/reboot-e2e"
mkdir -p "$REBOOT_DIR"
tar xf "${RUNNER_TEMP}/spinifex-tests.tar" -C "$REBOOT_DIR"
chmod +x "$REBOOT_DIR/run-reboot-e2e.sh"
WAN_IP="$WAN_IP" SSH_KEY="$SSH_KEY" "$REBOOT_DIR/run-reboot-e2e.sh"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
+37 -21
View File
@@ -146,27 +146,43 @@ func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
// when it's a bridge (Linux or OVS, br-* prefix). The old detectedWanBridge()
// returned hardcoded "br-ext" for Linux bridges — that value broke DHCP on
// consumer-router LANs because br-ext never sees LAN DHCP traffic (mulga-998).
func TestDetectedDhcpBindBridge_LinuxBridgeDefaultRoute(t *testing.T) {
detected := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-wan"}}
assert.Equal(t, "br-wan", detectedDhcpBindBridge(detected),
"Linux bridge (WAN NIC enslaved) must return the bridge name, not 'br-ext'")
}
func TestDetectedDhcpBindBridge_OVSBridgeDefaultRoute(t *testing.T) {
detected := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-ext"}}
assert.Equal(t, "br-ext", detectedDhcpBindBridge(detected),
"OVS bridge (WAN NIC on br-*) returned verbatim")
}
func TestDetectedDhcpBindBridge_PhysicalNIC(t *testing.T) {
detected := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "enp0s3"}}
assert.Equal(t, "br-wan", detectedDhcpBindBridge(detected),
"bare NIC defaults to 'br-wan' (the bridge the installer will create)")
}
func TestDetectedDhcpBindBridge_NilWAN(t *testing.T) {
assert.Empty(t, detectedDhcpBindBridge(nil))
assert.Empty(t, detectedDhcpBindBridge(&admin.DetectedNetwork{}))
func TestDetectedDhcpBindBridge(t *testing.T) {
tests := []struct {
name string
detected *admin.DetectedNetwork
want string
}{
{
name: "Linux bridge default route returns bridge name (not 'br-ext')",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-wan"}},
want: "br-wan",
},
{
name: "OVS bridge default route returned verbatim",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-ext"}},
want: "br-ext",
},
{
name: "physical NIC defaults to 'br-wan' (bridge the installer creates)",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "enp0s3"}},
want: "br-wan",
},
{
name: "nil DetectedNetwork",
detected: nil,
want: "",
},
{
name: "DetectedNetwork with nil WAN",
detected: &admin.DetectedNetwork{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, detectedDhcpBindBridge(tt.detected))
})
}
}
// Legacy `wan_bridge` TOML key must fail-start vpcd with guidance, not silently
+7 -4
View File
@@ -54,12 +54,15 @@ func DefaultConfigFile() string {
return filepath.Join(DefaultConfigDir(), "spinifex.toml")
}
// productionMarkerPath identifies a production install by directory presence.
// Created by setup.sh during binary install. Overridable in tests.
var productionMarkerPath = "/etc/spinifex"
// isProductionLayout returns true when running in a production install.
// Detected by: /etc/spinifex directory exists (created by setup.sh during
// binary install). No root check — allows non-root users (e.g. tf-user) to
// run CLI commands like `spx get nodes` without sudo or --config flags.
// No root check — allows non-root users (e.g. tf-user) to run CLI commands
// like `spx get nodes` without sudo or --config flags.
func isProductionLayout() bool {
if info, err := os.Stat("/etc/spinifex"); err == nil && info.IsDir() {
if info, err := os.Stat(productionMarkerPath); err == nil && info.IsDir() {
return true
}
return false
+62 -42
View File
@@ -1,57 +1,77 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDefaultConfigDir_Dev(t *testing.T) {
// Without /etc/spinifex should get dev paths
if _, err := os.Stat("/etc/spinifex"); err == nil {
t.Skip("test requires /etc/spinifex to not exist")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config")
got := DefaultConfigDir()
if got != expected {
t.Errorf("DefaultConfigDir() = %q, want %q", got, expected)
}
// withProductionMarker temporarily overrides the marker path used to detect a
// production install. Tests that pass an empty string force "dev" mode by
// pointing at a non-existent path; tests that pass t.TempDir() force
// "production" mode by pointing at a real directory.
func withProductionMarker(t *testing.T, path string) {
t.Helper()
orig := productionMarkerPath
productionMarkerPath = path
t.Cleanup(func() { productionMarkerPath = orig })
}
func TestDefaultDataDir_Dev(t *testing.T) {
if _, err := os.Stat("/etc/spinifex"); err == nil {
t.Skip("test requires /etc/spinifex to not exist")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex")
got := DefaultDataDir()
if got != expected {
t.Errorf("DefaultDataDir() = %q, want %q", got, expected)
}
func TestIsProductionLayout(t *testing.T) {
t.Run("absent marker returns false", func(t *testing.T) {
withProductionMarker(t, filepath.Join(t.TempDir(), "does-not-exist"))
assert.False(t, isProductionLayout())
})
t.Run("present marker returns true", func(t *testing.T) {
withProductionMarker(t, t.TempDir())
assert.True(t, isProductionLayout())
})
}
func TestDefaultConfigFile(t *testing.T) {
if _, err := os.Stat("/etc/spinifex"); err == nil {
t.Skip("test requires /etc/spinifex to not exist")
func TestDefaultPaths(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("SUDO_USER", "")
tests := []struct {
name string
productionMode bool
wantConfigDir string
wantDataDir string
wantConfigFile string
wantLogDir string // for LogDirFor("/data/dir")
}{
{
name: "dev layout",
productionMode: false,
wantConfigDir: filepath.Join(homeDir, "spinifex", "config"),
wantDataDir: filepath.Join(homeDir, "spinifex"),
wantConfigFile: filepath.Join(homeDir, "spinifex", "config", "spinifex.toml"),
wantLogDir: "/data/dir/logs",
},
{
name: "production layout",
productionMode: true,
wantConfigDir: "/etc/spinifex",
wantDataDir: "/var/lib/spinifex",
wantConfigFile: "/etc/spinifex/spinifex.toml",
wantLogDir: "/var/log/spinifex",
},
}
got := DefaultConfigFile()
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config", "spinifex.toml")
if got != expected {
t.Errorf("DefaultConfigFile() = %q, want %q", got, expected)
}
}
func TestIsProductionLayout_NoEtcSpinifex(t *testing.T) {
if _, err := os.Stat("/etc/spinifex"); err == nil {
t.Skip("test requires /etc/spinifex to not exist")
}
if isProductionLayout() {
t.Error("isProductionLayout() = true without /etc/spinifex, want false")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.productionMode {
withProductionMarker(t, t.TempDir())
} else {
withProductionMarker(t, filepath.Join(t.TempDir(), "absent"))
}
assert.Equal(t, tt.wantConfigDir, DefaultConfigDir())
assert.Equal(t, tt.wantDataDir, DefaultDataDir())
assert.Equal(t, tt.wantConfigFile, DefaultConfigFile())
assert.Equal(t, tt.wantLogDir, LogDirFor("/data/dir"))
})
}
}
-29
View File
@@ -1,29 +0,0 @@
package cmd
import (
"testing"
)
func TestVersionVarsExist(t *testing.T) {
// Verify the version variables are set (defaults when not injected via ldflags)
if Version == "" {
t.Error("Version should not be empty")
}
if Commit == "" {
t.Error("Commit should not be empty")
}
}
func TestVersionCommand(t *testing.T) {
// Verify the version command is registered
found := false
for _, cmd := range rootCmd.Commands() {
if cmd.Use == "version" {
found = true
break
}
}
if !found {
t.Error("version command not registered on rootCmd")
}
}
+42 -29
View File
@@ -217,40 +217,53 @@ expected_nodes = 3
// Tests for HasService / GetServices
func TestHasService_ExplicitList(t *testing.T) {
c := Config{Services: []string{"nats", "daemon"}}
func TestHasService(t *testing.T) {
tests := []struct {
name string
services []string
query string
want bool
}{
{"explicit list, member", []string{"nats", "daemon"}, "nats", true},
{"explicit list, second member", []string{"nats", "daemon"}, "daemon", true},
{"explicit list, non-member", []string{"nats", "daemon"}, "predastore", false},
{"explicit list, queried name unknown", []string{"nats"}, "unknown", false},
// A typo in the configured list ("natz") must not satisfy a query for
// the real service name — Contains is name-exact, no fuzzy match.
{"explicit list with typo entry", []string{"natz"}, "nats", false},
{"nil list defaults to AllServices", nil, "viperblock", true},
{"empty list defaults to AllServices", []string{}, "ui", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := Config{Services: tt.services}
assert.Equal(t, tt.want, c.HasService(tt.query))
})
}
assert.True(t, c.HasService("nats"))
assert.True(t, c.HasService("daemon"))
assert.False(t, c.HasService("predastore"))
assert.False(t, c.HasService("viperblock"))
assert.False(t, c.HasService("ui"))
}
func TestHasService_EmptyListBackwardCompat(t *testing.T) {
c := Config{} // no Services set
// Empty list means all services
// Sanity: empty list grants every documented service.
empty := Config{}
for _, svc := range AllServices {
assert.True(t, c.HasService(svc), "expected %s to be available with empty list", svc)
assert.True(t, empty.HasService(svc), "empty list must include %q", svc)
}
}
func TestHasService_UnknownService(t *testing.T) {
c := Config{Services: []string{"nats"}}
assert.False(t, c.HasService("unknown"))
}
func TestGetServices_DefaultsToAll(t *testing.T) {
c := Config{}
services := c.GetServices()
assert.Equal(t, AllServices, services)
}
func TestGetServices_ExplicitList(t *testing.T) {
c := Config{Services: []string{"nats", "predastore"}}
services := c.GetServices()
assert.Equal(t, []string{"nats", "predastore"}, services)
func TestGetServices(t *testing.T) {
tests := []struct {
name string
services []string
want []string
}{
{"nil defaults to AllServices", nil, AllServices},
{"empty defaults to AllServices", []string{}, AllServices},
{"explicit list returned as-is", []string{"nats", "predastore"}, []string{"nats", "predastore"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := Config{Services: tt.services}
assert.Equal(t, tt.want, c.GetServices())
})
}
}
// Tests for NodeBaseDir
+40 -44
View File
@@ -41,40 +41,51 @@ func TestNewMgmtIPAllocator(t *testing.T) {
}
func TestMgmtIPAllocator_Allocate(t *testing.T) {
a, err := NewMgmtIPAllocator("10.15.8.1")
if err != nil {
t.Fatal(err)
tests := []struct {
name string
bridgeIP string
firstIP string
secondIP string
}{
{"primary subnet", "10.15.8.1", "10.15.8.10", "10.15.8.11"},
{"different subnet", "192.168.1.33", "192.168.1.10", "192.168.1.11"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a, err := NewMgmtIPAllocator(tt.bridgeIP)
if err != nil {
t.Fatal(err)
}
// First allocation gets .10
ip, err := a.Allocate("i-first")
if err != nil {
t.Fatal(err)
}
if ip != "10.15.8.10" {
t.Errorf("first IP = %q, want 10.15.8.10", ip)
}
ip, err := a.Allocate("i-first")
if err != nil {
t.Fatal(err)
}
if ip != tt.firstIP {
t.Errorf("first IP = %q, want %q", ip, tt.firstIP)
}
// Second allocation gets .11
ip, err = a.Allocate("i-second")
if err != nil {
t.Fatal(err)
}
if ip != "10.15.8.11" {
t.Errorf("second IP = %q, want 10.15.8.11", ip)
}
ip, err = a.Allocate("i-second")
if err != nil {
t.Fatal(err)
}
if ip != tt.secondIP {
t.Errorf("second IP = %q, want %q", ip, tt.secondIP)
}
// Re-allocating same instance returns same IP
ip, err = a.Allocate("i-first")
if err != nil {
t.Fatal(err)
}
if ip != "10.15.8.10" {
t.Errorf("re-allocate = %q, want 10.15.8.10", ip)
}
// Re-allocating same instance returns same IP
ip, err = a.Allocate("i-first")
if err != nil {
t.Fatal(err)
}
if ip != tt.firstIP {
t.Errorf("re-allocate = %q, want %q", ip, tt.firstIP)
}
if a.AllocatedCount() != 2 {
t.Errorf("count = %d, want 2", a.AllocatedCount())
if a.AllocatedCount() != 2 {
t.Errorf("count = %d, want 2", a.AllocatedCount())
}
})
}
}
@@ -171,21 +182,6 @@ func TestMgmtIPAllocator_Rebuild(t *testing.T) {
}
}
func TestMgmtIPAllocator_DifferentSubnet(t *testing.T) {
a, err := NewMgmtIPAllocator("192.168.1.33")
if err != nil {
t.Fatal(err)
}
ip, err := a.Allocate("i-test")
if err != nil {
t.Fatal(err)
}
if ip != "192.168.1.10" {
t.Errorf("IP = %q, want 192.168.1.10", ip)
}
}
func TestMgmtIPAllocator_Concurrent(t *testing.T) {
a, err := NewMgmtIPAllocator("10.15.8.1")
if err != nil {
+86 -123
View File
@@ -53,142 +53,105 @@ func TestExtractPCIIndex(t *testing.T) {
}
}
// TestBuildDeviceMap tests the core device mapping logic using the example
// QMP query-block response from qmp.go:72
// TestBuildDeviceMap covers the core device-mapping logic across boot devices,
// hot-plugged virtio peripherals, non-virtio devices that must be filtered out,
// PCI-index ordering across mixed boot/hot-plug input, and the empty case.
func TestBuildDeviceMap(t *testing.T) {
// Simulate the exact QMP response from the example in qmp.go
devices := []qmp.BlockDevice{
tests := []struct {
name string
devices []qmp.BlockDevice
want map[string]string // expected entries (subset checked with Equal)
absent []string // keys that must NOT appear
wantLen int
}{
{
IOStatus: "ok",
Device: "os",
Locked: false,
Inserted: &qmp.BlockInserted{
Image: qmp.BlockImage{
VirtualSize: 4294967296,
Filename: "nbd://127.0.0.1:44801",
Format: "raw",
name: "empty input",
devices: nil,
want: map[string]string{},
wantLen: 0,
},
{
// Mirrors the example QMP query-block response from qmp.go:72
name: "boot devices with non-virtio devices filtered out",
devices: []qmp.BlockDevice{
{
IOStatus: "ok",
Device: "os",
Inserted: &qmp.BlockInserted{
Image: qmp.BlockImage{VirtualSize: 4294967296, Filename: "nbd://127.0.0.1:44801", Format: "raw"},
},
QDev: "/machine/peripheral-anon/device[0]/virtio-backend",
Type: "unknown",
},
},
QDev: "/machine/peripheral-anon/device[0]/virtio-backend",
Type: "unknown",
},
{
IOStatus: "ok",
Device: "cloudinit",
Locked: false,
Inserted: &qmp.BlockInserted{
Image: qmp.BlockImage{
VirtualSize: 1048576,
Filename: "nbd://127.0.0.1:42911",
Format: "raw",
{
IOStatus: "ok",
Device: "cloudinit",
Inserted: &qmp.BlockInserted{
Image: qmp.BlockImage{VirtualSize: 1048576, Filename: "nbd://127.0.0.1:42911", Format: "raw"},
RO: true,
},
QDev: "/machine/peripheral-anon/device[3]/virtio-backend",
Type: "unknown",
},
RO: true,
{Device: "ide1-cd0", Removable: true, QDev: "/machine/unattached/device[24]"},
{Device: "floppy0", Removable: true, QDev: "/machine/unattached/device[18]"},
{Device: "sd0", Removable: true},
},
QDev: "/machine/peripheral-anon/device[3]/virtio-backend",
Type: "unknown",
want: map[string]string{"os": "/dev/vda", "cloudinit": "/dev/vdb"},
absent: []string{"ide1-cd0", "floppy0", "sd0"},
wantLen: 2,
},
{
IOStatus: "ok",
Device: "ide1-cd0",
Locked: false,
Removable: true,
QDev: "/machine/unattached/device[24]",
Type: "unknown",
// Hot-plugged devices have empty Device field and use the
// /machine/peripheral/<id>/virtio-backend QDev path format.
name: "boot plus hot-plugged devices",
devices: []qmp.BlockDevice{
{Device: "os", Inserted: &qmp.BlockInserted{}, QDev: "/machine/peripheral-anon/device[0]/virtio-backend"},
{Device: "cloudinit", Inserted: &qmp.BlockInserted{}, QDev: "/machine/peripheral-anon/device[3]/virtio-backend"},
{Device: "", Inserted: &qmp.BlockInserted{}, QDev: "/machine/peripheral/vdisk-vol-abc123/virtio-backend"},
{Device: "", Inserted: &qmp.BlockInserted{}, QDev: "/machine/peripheral/vdisk-vol-def456/virtio-backend"},
{Device: "floppy0", QDev: "/machine/unattached/device[18]"},
{Device: "sd0"},
{Device: "ide1-cd0", QDev: "/machine/unattached/device[24]"},
},
want: map[string]string{
"os": "/dev/vda",
"cloudinit": "/dev/vdb",
"vdisk-vol-abc123": "/dev/vdc",
"vdisk-vol-def456": "/dev/vdd",
},
wantLen: 4,
},
{
Device: "floppy0",
Locked: false,
Removable: true,
QDev: "/machine/unattached/device[18]",
Type: "unknown",
},
{
Device: "sd0",
Locked: false,
Removable: true,
Type: "unknown",
// Boot devices returned out of order: lowest PCI index wins /dev/vda;
// hot-plugged devices sort after boot devices.
name: "PCI-index ordering across mixed boot and hot-plug",
devices: []qmp.BlockDevice{
{Device: "cloudinit", Inserted: &qmp.BlockInserted{}, QDev: "/machine/peripheral-anon/device[5]/virtio-backend"},
{Device: "os", Inserted: &qmp.BlockInserted{}, QDev: "/machine/peripheral-anon/device[1]/virtio-backend"},
{Device: "", Inserted: &qmp.BlockInserted{}, QDev: "/machine/peripheral/vdisk-vol-123/virtio-backend"},
},
want: map[string]string{
"os": "/dev/vda",
"cloudinit": "/dev/vdb",
"vdisk-vol-123": "/dev/vdc",
},
wantLen: 3,
},
}
result := buildDeviceMap(devices)
assert.Equal(t, "/dev/vda", result["os"], "root disk should be /dev/vda")
assert.Equal(t, "/dev/vdb", result["cloudinit"], "cloudinit should be /dev/vdb")
assert.NotContains(t, result, "ide1-cd0", "ide device should be excluded")
assert.NotContains(t, result, "floppy0", "floppy should be excluded")
assert.NotContains(t, result, "sd0", "sd device should be excluded")
assert.Len(t, result, 2, "should only have 2 virtio devices")
}
func TestBuildDeviceMapWithHotplug(t *testing.T) {
// Hot-plugged devices have empty Device field and use
// /machine/peripheral/<id>/virtio-backend QDev path format
devices := []qmp.BlockDevice{
{
Device: "os",
Inserted: &qmp.BlockInserted{},
QDev: "/machine/peripheral-anon/device[0]/virtio-backend",
},
{
Device: "cloudinit",
Inserted: &qmp.BlockInserted{},
QDev: "/machine/peripheral-anon/device[3]/virtio-backend",
},
{
Device: "",
Inserted: &qmp.BlockInserted{},
QDev: "/machine/peripheral/vdisk-vol-abc123/virtio-backend",
},
{
Device: "",
Inserted: &qmp.BlockInserted{},
QDev: "/machine/peripheral/vdisk-vol-def456/virtio-backend",
},
// Legacy devices to filter out
{Device: "floppy0", QDev: "/machine/unattached/device[18]"},
{Device: "sd0"},
{Device: "ide1-cd0", QDev: "/machine/unattached/device[24]"},
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildDeviceMap(tt.devices)
for k, v := range tt.want {
assert.Equal(t, v, got[k], "device %q", k)
}
for _, k := range tt.absent {
assert.NotContains(t, got, k)
}
assert.Len(t, got, tt.wantLen)
})
}
result := buildDeviceMap(devices)
assert.Equal(t, "/dev/vda", result["os"])
assert.Equal(t, "/dev/vdb", result["cloudinit"])
assert.Equal(t, "/dev/vdc", result["vdisk-vol-abc123"])
assert.Equal(t, "/dev/vdd", result["vdisk-vol-def456"])
assert.Len(t, result, 4)
}
func TestBuildDeviceMapEmpty(t *testing.T) {
result := buildDeviceMap(nil)
assert.Empty(t, result)
}
func TestBuildDeviceMapPCIOrdering(t *testing.T) {
// Boot devices returned out of order + hot-plugged device
devices := []qmp.BlockDevice{
{
Device: "cloudinit",
Inserted: &qmp.BlockInserted{},
QDev: "/machine/peripheral-anon/device[5]/virtio-backend",
},
{
Device: "os",
Inserted: &qmp.BlockInserted{},
QDev: "/machine/peripheral-anon/device[1]/virtio-backend",
},
{
Device: "",
Inserted: &qmp.BlockInserted{},
QDev: "/machine/peripheral/vdisk-vol-123/virtio-backend",
},
}
result := buildDeviceMap(devices)
assert.Equal(t, "/dev/vda", result["os"], "lowest PCI index gets /dev/vda")
assert.Equal(t, "/dev/vdb", result["cloudinit"], "second boot device")
assert.Equal(t, "/dev/vdc", result["vdisk-vol-123"], "hot-plugged device sorts after boot devices")
}
func TestExtractPeripheralName(t *testing.T) {
+28 -17
View File
@@ -1,6 +1,7 @@
package daemon
import (
"encoding/json"
"testing"
"github.com/mulgadc/spinifex/spinifex/config"
@@ -90,19 +91,21 @@ func TestHeartbeatReflectsAllocation(t *testing.T) {
assert.Less(t, after.AvailableVCPU, before.AvailableVCPU, "AvailableVCPU should decrease")
}
// TestHeartbeatKVRoundTrip verifies that heartbeat can be written to and read from KV.
func TestHeartbeatKVRoundTrip(t *testing.T) {
// TestHeartbeatKVContract pins the on-wire JSON shape and the KV key layout
// for WriteHeartbeat/ReadHeartbeat. A renamed struct tag or changed key
// prefix would break cross-version cluster reads — a pure round-trip would
// not catch either, so we assert against the raw KV bytes.
func TestHeartbeatKVContract(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitClusterStateBucket()
require.NoError(t, err)
require.NoError(t, jsm.InitClusterStateBucket())
h := &Heartbeat{
Node: "kv-test-node",
Node: "kv-contract-node",
Epoch: 3,
Timestamp: "2025-01-01T00:00:00Z",
Services: []string{"daemon", "nats"},
@@ -113,20 +116,28 @@ func TestHeartbeatKVRoundTrip(t *testing.T) {
AvailableMem: 24.0,
}
err = jsm.WriteHeartbeat(h)
require.NoError(t, jsm.WriteHeartbeat(h))
// Verify the KV key prefix and the JSON tag names directly. Reading back
// via ReadHeartbeat alone would also pass against a typo'd tag.
entry, err := jsm.clusterKV.Get("heartbeat." + h.Node)
require.NoError(t, err)
loaded, err := jsm.ReadHeartbeat("kv-test-node")
var raw map[string]any
require.NoError(t, json.Unmarshal(entry.Value(), &raw))
assert.Equal(t, "kv-contract-node", raw["node"])
assert.Equal(t, float64(3), raw["epoch"])
assert.Equal(t, "2025-01-01T00:00:00Z", raw["timestamp"])
assert.Equal(t, []any{"daemon", "nats"}, raw["services"])
assert.Equal(t, float64(2), raw["vm_count"])
assert.Equal(t, float64(4), raw["allocated_vcpu"])
assert.Equal(t, float64(12), raw["available_vcpu"])
assert.Equal(t, 8.0, raw["allocated_mem_gb"])
assert.Equal(t, 24.0, raw["available_mem_gb"])
// And ReadHeartbeat decodes the same value.
loaded, err := jsm.ReadHeartbeat(h.Node)
require.NoError(t, err)
require.NotNil(t, loaded)
assert.Equal(t, h.Node, loaded.Node)
assert.Equal(t, h.Epoch, loaded.Epoch)
assert.Equal(t, h.Timestamp, loaded.Timestamp)
assert.Equal(t, h.Services, loaded.Services)
assert.Equal(t, h.VMCount, loaded.VMCount)
assert.Equal(t, h.AllocatedVCPU, loaded.AllocatedVCPU)
assert.Equal(t, h.AvailableVCPU, loaded.AvailableVCPU)
assert.Equal(t, h.AllocatedMem, loaded.AllocatedMem)
assert.Equal(t, h.AvailableMem, loaded.AvailableMem)
assert.Equal(t, h, loaded)
}
+2
View File
@@ -36,6 +36,8 @@ type NetworkPlumber interface {
// OVSNetworkPlumber implements NetworkPlumber using system commands.
type OVSNetworkPlumber struct{}
var _ NetworkPlumber = (*OVSNetworkPlumber)(nil)
func (p *OVSNetworkPlumber) SetupTapDevice(eniId, mac string) error {
tapName := TapDeviceName(eniId)
ifaceID := OVSIfaceID(eniId)
+4 -81
View File
@@ -34,25 +34,6 @@ func TestTapDeviceName(t *testing.T) {
}
}
func TestOVSIfaceID(t *testing.T) {
tests := []struct {
eniId string
expected string
}{
{"eni-abc123", "port-eni-abc123"},
{"eni-abc123def456789", "port-eni-abc123def456789"},
}
for _, tt := range tests {
t.Run(tt.eniId, func(t *testing.T) {
got := OVSIfaceID(tt.eniId)
if got != tt.expected {
t.Errorf("OVSIfaceID(%q) = %q, want %q", tt.eniId, got, tt.expected)
}
})
}
}
// MockNetworkPlumber records calls for testing.
type MockNetworkPlumber struct {
SetupCalls []mockSetupCall
@@ -61,6 +42,8 @@ type MockNetworkPlumber struct {
CleanupErr error
}
var _ NetworkPlumber = (*MockNetworkPlumber)(nil)
type mockSetupCall struct {
ENIId string
MAC string
@@ -232,12 +215,6 @@ func TestGenerateDevMAC(t *testing.T) {
}
}
func TestNetworkPlumber_InterfaceCompliance(t *testing.T) {
// Verify both types satisfy the interface
var _ NetworkPlumber = &OVSNetworkPlumber{}
var _ NetworkPlumber = &MockNetworkPlumber{}
}
func TestSetupExtraENINICs_AppendsOnePerExtra(t *testing.T) {
mock := &MockNetworkPlumber{}
d := &Daemon{networkPlumber: mock}
@@ -371,62 +348,6 @@ func TestCleanupExtraENITaps_ErrorsAreLogged(t *testing.T) {
}
}
func TestOVNHealthStatus_Fields(t *testing.T) {
// Verify OVNHealthStatus struct can be used for health reporting
status := OVNHealthStatus{
BrIntExists: true,
OVNControllerUp: true,
ChassisID: "chassis-node1",
EncapIP: "10.0.0.1",
OVNRemote: "tcp:10.0.0.1:6642",
}
if !status.BrIntExists {
t.Error("expected BrIntExists to be true")
}
if !status.OVNControllerUp {
t.Error("expected OVNControllerUp to be true")
}
if status.ChassisID != "chassis-node1" {
t.Errorf("ChassisID = %q, want 'chassis-node1'", status.ChassisID)
}
if status.EncapIP != "10.0.0.1" {
t.Errorf("EncapIP = %q, want '10.0.0.1'", status.EncapIP)
}
if status.OVNRemote != "tcp:10.0.0.1:6642" {
t.Errorf("OVNRemote = %q, want 'tcp:10.0.0.1:6642'", status.OVNRemote)
}
}
func TestOVNHealthStatus_Defaults(t *testing.T) {
// Zero-value OVNHealthStatus should indicate nothing is ready
var status OVNHealthStatus
if status.BrIntExists {
t.Error("zero-value BrIntExists should be false")
}
if status.OVNControllerUp {
t.Error("zero-value OVNControllerUp should be false")
}
if status.ChassisID != "" {
t.Errorf("zero-value ChassisID should be empty, got %q", status.ChassisID)
}
}
func TestCheckOVNHealth_ReturnsStatus(t *testing.T) {
// CheckOVNHealth should return a status struct without panicking,
// even when OVS/OVN tools are not installed (CI environment).
// On a dev machine without OVN, all fields will be zero values.
status := CheckOVNHealth()
// On CI without OVS, both should be false — just verify no panic
_ = status.BrIntExists
_ = status.OVNControllerUp
_ = status.ChassisID
_ = status.EncapIP
_ = status.OVNRemote
}
func TestFindInterfaceByIP_InvalidIP(t *testing.T) {
_, err := findInterfaceByIP("not-an-ip")
if err == nil {
@@ -571,6 +492,8 @@ func TestOVSIfaceID_Format(t *testing.T) {
eniId string
expected string
}{
{"eni-abc123", "port-eni-abc123"},
{"eni-abc123def456789", "port-eni-abc123def456789"},
{"eni-short", "port-eni-short"},
{"eni-", "port-eni-"},
{"", "port-"},
-82
View File
@@ -11,88 +11,6 @@ import (
"github.com/stretchr/testify/require"
)
// TestShutdownACKMarshal verifies JSON round-trip for ShutdownACK.
func TestShutdownACKMarshal(t *testing.T) {
ack := ShutdownACK{
Node: "node1",
Phase: "gate",
Stopped: []string{"awsgw", "spinifex-ui"},
}
data, err := json.Marshal(ack)
require.NoError(t, err)
var decoded ShutdownACK
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, ack.Node, decoded.Node)
assert.Equal(t, ack.Phase, decoded.Phase)
assert.Equal(t, ack.Stopped, decoded.Stopped)
assert.Empty(t, decoded.Error)
}
// TestShutdownACKWithError verifies JSON round-trip for ShutdownACK with an error.
func TestShutdownACKWithError(t *testing.T) {
ack := ShutdownACK{
Node: "node2",
Phase: "drain",
Error: "failed to stop VMs",
}
data, err := json.Marshal(ack)
require.NoError(t, err)
var decoded ShutdownACK
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, "failed to stop VMs", decoded.Error)
assert.Nil(t, decoded.Stopped)
}
// TestShutdownRequestMarshal verifies JSON round-trip for ShutdownRequest.
func TestShutdownRequestMarshal(t *testing.T) {
req := ShutdownRequest{
Phase: "drain",
Force: true,
Timeout: 120,
}
data, err := json.Marshal(req)
require.NoError(t, err)
var decoded ShutdownRequest
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, "drain", decoded.Phase)
assert.True(t, decoded.Force)
assert.Equal(t, 120, decoded.Timeout)
}
// TestShutdownProgressMarshal verifies JSON round-trip for ShutdownProgress.
func TestShutdownProgressMarshal(t *testing.T) {
progress := ShutdownProgress{
Node: "node1",
Phase: "drain",
Total: 5,
Remaining: 3,
}
data, err := json.Marshal(progress)
require.NoError(t, err)
var decoded ShutdownProgress
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, "node1", decoded.Node)
assert.Equal(t, "drain", decoded.Phase)
assert.Equal(t, 5, decoded.Total)
assert.Equal(t, 3, decoded.Remaining)
}
// TestClusterShutdownStateKVRoundTrip verifies cluster shutdown state can be stored and retrieved from KV.
func TestClusterShutdownStateKVRoundTrip(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
+155 -158
View File
@@ -315,25 +315,6 @@ func TestDescribeImages_MixedExistingAndMissing(t *testing.T) {
assert.Equal(t, awserrors.ErrorInvalidAMIIDNotFound, err.Error())
}
func TestDescribeImages_FilterByOwnerSelf(t *testing.T) {
svc, store := setupTestImageService(t)
// Create an AMI owned by the caller's account
createTestAMIConfigWithOwner(t, store, "ami-selfowned", "self-owned-ami", testAccountID)
// Create a system AMI (should not appear with --owners self)
createTestAMIConfigWithOwner(t, store, "ami-system", "system-ami", "spinifex")
// Filter by "self" should return only the caller's AMI
result, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Owners: []*string{aws.String("self")},
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Images, 1)
assert.Equal(t, "ami-selfowned", *result.Images[0].ImageId)
}
func TestCreateImageFromInstance_DuplicateName(t *testing.T) {
svc, store := setupTestImageService(t)
@@ -733,27 +714,6 @@ func TestDescribeImages_WithTags(t *testing.T) {
assert.Len(t, img.BlockDeviceMappings, 1)
}
func TestDescribeImages_FilterByExplicitOwnerID(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithOwner(t, store, "ami-owned1", "owned-ami", testAccountID)
// Filter by explicit account ID
result, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Owners: []*string{aws.String(testAccountID)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, result.Images, 1)
assert.Equal(t, "ami-owned1", *result.Images[0].ImageId)
// Filter by wrong account ID — should return empty
result, err = svc.DescribeImages(&ec2.DescribeImagesInput{
Owners: []*string{aws.String("999999999999")},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, result.Images)
}
func TestDescribeImages_OwnerFilterNilEntry(t *testing.T) {
svc, store := setupTestImageService(t)
@@ -865,40 +825,163 @@ func createTestAMIConfigFull(t *testing.T, store *objectstore.MemoryObjectStore,
require.NoError(t, err)
}
func TestDescribeImages_FilterByName(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "debian-12")
createTestAMIConfigWithName(t, store, "ami-bbb", "ubuntu-22")
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("name"), Values: []*string{aws.String("debian-12")}},
// TestDescribeImages_FilterBy verifies single-attribute filters. Each subtest
// builds its own AMI fixtures and asserts which IDs the filter selects.
// Multi-filter, wildcard, error-path, and no-filter cases live in their own
// dedicated tests below.
func TestDescribeImages_FilterBy(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, store *objectstore.MemoryObjectStore)
input *ec2.DescribeImagesInput
wantIDs []string // matched image IDs (order not asserted)
wantNone bool // expect zero results
}{
{
name: "owner self excludes other owners",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigWithOwner(t, store, "ami-selfowned", "self-owned-ami", testAccountID)
createTestAMIConfigWithOwner(t, store, "ami-system", "system-ami", "spinifex")
},
input: &ec2.DescribeImagesInput{Owners: []*string{aws.String("self")}},
wantIDs: []string{"ami-selfowned"},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-aaa", *out.Images[0].ImageId)
}
func TestDescribeImages_FilterByArchitecture(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-x86", Name: "x86-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-arm", Name: "arm-img", Architecture: "arm64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("architecture"), Values: []*string{aws.String("arm64")}},
{
name: "explicit owner ID matches caller",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigWithOwner(t, store, "ami-owned1", "owned-ami", testAccountID)
},
input: &ec2.DescribeImagesInput{Owners: []*string{aws.String(testAccountID)}},
wantIDs: []string{"ami-owned1"},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-arm", *out.Images[0].ImageId)
{
name: "explicit owner ID with wrong account returns empty",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigWithOwner(t, store, "ami-owned1", "owned-ami", testAccountID)
},
input: &ec2.DescribeImagesInput{Owners: []*string{aws.String("999999999999")}},
wantNone: true,
},
{
name: "name",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigWithName(t, store, "ami-aaa", "debian-12")
createTestAMIConfigWithName(t, store, "ami-bbb", "ubuntu-22")
},
input: &ec2.DescribeImagesInput{Filters: []*ec2.Filter{
{Name: aws.String("name"), Values: []*string{aws.String("debian-12")}},
}},
wantIDs: []string{"ami-aaa"},
},
{
name: "architecture",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-x86", Name: "x86-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-arm", Name: "arm-img", Architecture: "arm64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
},
input: &ec2.DescribeImagesInput{Filters: []*ec2.Filter{
{Name: aws.String("architecture"), Values: []*string{aws.String("arm64")}},
}},
wantIDs: []string{"ami-arm"},
},
{
name: "tag",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-tagged", Name: "tagged-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
Tags: map[string]string{"Environment": "prod"},
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-untagged", Name: "untagged-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
},
input: &ec2.DescribeImagesInput{Filters: []*ec2.Filter{
{Name: aws.String("tag:Environment"), Values: []*string{aws.String("prod")}},
}},
wantIDs: []string{"ami-tagged"},
},
{
name: "state available",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigWithName(t, store, "ami-aaa", "test")
},
input: &ec2.DescribeImagesInput{Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("available")}},
}},
wantIDs: []string{"ami-aaa"},
},
{
name: "state deregistered returns empty",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigWithName(t, store, "ami-aaa", "test")
},
input: &ec2.DescribeImagesInput{Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("deregistered")}},
}},
wantNone: true,
},
{
name: "virtualization type",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-hvm", Name: "hvm-img", Architecture: "x86_64",
Virtualization: "hvm", RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-pv", Name: "pv-img", Architecture: "x86_64",
Virtualization: "paravirtual", RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
},
input: &ec2.DescribeImagesInput{Filters: []*ec2.Filter{
{Name: aws.String("virtualization-type"), Values: []*string{aws.String("hvm")}},
}},
wantIDs: []string{"ami-hvm"},
},
{
name: "root device type",
setup: func(t *testing.T, store *objectstore.MemoryObjectStore) {
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-ebs", Name: "ebs-img", Architecture: "x86_64",
Virtualization: "hvm", RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-is", Name: "is-img", Architecture: "x86_64",
Virtualization: "hvm", RootDeviceType: "instance-store", VolumeSizeGiB: 8,
})
},
input: &ec2.DescribeImagesInput{Filters: []*ec2.Filter{
{Name: aws.String("root-device-type"), Values: []*string{aws.String("ebs")}},
}},
wantIDs: []string{"ami-ebs"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, store := setupTestImageService(t)
tt.setup(t, store)
out, err := svc.DescribeImages(tt.input, testAccountID)
require.NoError(t, err)
if tt.wantNone {
assert.Empty(t, out.Images)
return
}
gotIDs := make([]string, len(out.Images))
for i, img := range out.Images {
gotIDs[i] = aws.StringValue(img.ImageId)
}
assert.ElementsMatch(t, tt.wantIDs, gotIDs)
})
}
}
func TestDescribeImages_FilterMultipleValues_OR(t *testing.T) {
@@ -987,92 +1070,6 @@ func TestDescribeImages_FilterNoFilters(t *testing.T) {
assert.Len(t, out.Images, 2)
}
func TestDescribeImages_FilterByTag(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-tagged", Name: "tagged-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
Tags: map[string]string{"Environment": "prod"},
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-untagged", Name: "untagged-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Environment"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-tagged", *out.Images[0].ImageId)
}
func TestDescribeImages_FilterByState(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "test")
// All images have state "available"
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("available")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
out, err = svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("deregistered")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Images)
}
func TestDescribeImages_FilterByVirtualizationType(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-hvm", Name: "hvm-img", Architecture: "x86_64",
Virtualization: "hvm", RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-pv", Name: "pv-img", Architecture: "x86_64",
Virtualization: "paravirtual", RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("virtualization-type"), Values: []*string{aws.String("hvm")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-hvm", *out.Images[0].ImageId)
}
func TestDescribeImages_FilterByRootDeviceType(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-ebs", Name: "ebs-img", Architecture: "x86_64",
Virtualization: "hvm", RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-is", Name: "is-img", Architecture: "x86_64",
Virtualization: "hvm", RootDeviceType: "instance-store", VolumeSizeGiB: 8,
})
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("root-device-type"), Values: []*string{aws.String("ebs")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-ebs", *out.Images[0].ImageId)
}
// --- RegisterImage tests ---
// putTestSnapshotConfig writes a SnapshotConfig at {snapshotID}/metadata.json,
@@ -10,32 +10,12 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewInstanceServiceImpl(t *testing.T) {
cfg := &config.Config{}
instanceTypes := map[string]*ec2.InstanceTypeInfo{
"t3.micro": {InstanceType: aws.String("t3.micro")},
}
store := objectstore.NewMemoryObjectStore()
instances := &vm.Instances{VMS: make(map[string]*vm.VM)}
svc := NewInstanceServiceImpl(cfg, instanceTypes, nil, instances, store)
require.NotNil(t, svc)
assert.Equal(t, cfg, svc.config)
assert.Equal(t, instanceTypes, svc.instanceTypes)
assert.Nil(t, svc.natsConn)
assert.Equal(t, instances, svc.instances)
assert.Equal(t, store, svc.objectStore)
}
func TestGenerateHostname(t *testing.T) {
tests := []struct {
name string
@@ -52,27 +52,6 @@ func importTestKey(t *testing.T, svc *KeyServiceImpl, keyName string) *ec2.Impor
return out
}
// --- TestKeyPairMetadataMarshaling (kept — validates JSON roundtrip independently) ---
func TestKeyPairMetadataMarshaling(t *testing.T) {
metadata := ec2.CreateKeyPairOutput{
KeyPairId: aws.String("key-12345"),
KeyFingerprint: aws.String("SHA256:abcdef1234567890"),
KeyName: aws.String("test-key"),
}
data, err := json.Marshal(metadata)
require.NoError(t, err)
var unmarshaled ec2.CreateKeyPairOutput
err = json.Unmarshal(data, &unmarshaled)
require.NoError(t, err)
assert.Equal(t, *metadata.KeyPairId, *unmarshaled.KeyPairId)
assert.Equal(t, *metadata.KeyFingerprint, *unmarshaled.KeyFingerprint)
assert.Equal(t, *metadata.KeyName, *unmarshaled.KeyName)
}
// ============================================================
// CreateKeyPair Tests
// ============================================================
@@ -1444,16 +1444,6 @@ func TestDescribeVolumeStatus_SlowPath_SkipsBrokenConfig(t *testing.T) {
assert.Len(t, output.VolumeStatuses, 1)
}
func TestNewVolumeServiceImplWithStore_WithSnapshotKV(t *testing.T) {
kv := setupTestVolumeKV(t)
store := objectstore.NewMemoryObjectStore()
cfg := &config.Config{
Predastore: config.PredastoreConfig{Bucket: "test-bucket"},
}
svc := NewVolumeServiceImplWithStore(cfg, store, nil, kv)
assert.NotNil(t, svc.snapshotKV)
}
func TestDeleteVolume_NATSErrorResponse(t *testing.T) {
kv := setupTestVolumeKV(t)
store := objectstore.NewMemoryObjectStore()
+75 -64
View File
@@ -8,72 +8,83 @@ import (
"github.com/stretchr/testify/assert"
)
func TestToHealthReport_AllFieldsPopulated(t *testing.T) {
in := &LBAgentHeartbeatInput{
LBID: aws.String("lb-abc123"),
Servers: []*LBAgentServerStatus{
{Backend: aws.String("tg-1"), Server: aws.String("10.0.0.1:80"), Status: aws.String("UP")},
{Backend: aws.String("tg-2"), Server: aws.String("10.0.0.2:80"), Status: aws.String("DOWN")},
// TestToHealthReport verifies the LBAgentHeartbeatInput.toHealthReport conversion,
// in particular that aws.StringValue handles nil pointers without panicking.
func TestToHealthReport(t *testing.T) {
tests := []struct {
name string
in *LBAgentHeartbeatInput
wantLB string
wantSrv []lbagent.ServerStatus
}{
{
name: "all fields populated",
in: &LBAgentHeartbeatInput{
LBID: aws.String("lb-abc123"),
Servers: []*LBAgentServerStatus{
{Backend: aws.String("tg-1"), Server: aws.String("10.0.0.1:80"), Status: aws.String("UP")},
{Backend: aws.String("tg-2"), Server: aws.String("10.0.0.2:80"), Status: aws.String("DOWN")},
},
},
wantLB: "lb-abc123",
wantSrv: []lbagent.ServerStatus{
{Backend: "tg-1", Server: "10.0.0.1:80", Status: "UP"},
{Backend: "tg-2", Server: "10.0.0.2:80", Status: "DOWN"},
},
},
{
name: "nil LBID",
in: &LBAgentHeartbeatInput{
LBID: nil,
Servers: []*LBAgentServerStatus{
{Backend: aws.String("tg-1"), Server: aws.String("10.0.0.1:80"), Status: aws.String("UP")},
},
},
wantLB: "",
wantSrv: []lbagent.ServerStatus{
{Backend: "tg-1", Server: "10.0.0.1:80", Status: "UP"},
},
},
{
name: "nil servers",
in: &LBAgentHeartbeatInput{LBID: aws.String("lb-abc123"), Servers: nil},
wantLB: "lb-abc123",
wantSrv: nil,
},
{
name: "empty input",
in: &LBAgentHeartbeatInput{},
wantLB: "",
wantSrv: nil,
},
{
name: "nil server fields",
in: &LBAgentHeartbeatInput{
LBID: aws.String("lb-abc123"),
Servers: []*LBAgentServerStatus{
{Backend: nil, Server: nil, Status: nil},
{Backend: aws.String("tg-1"), Server: nil, Status: aws.String("MAINT")},
{Backend: nil, Server: aws.String("10.0.0.3:80"), Status: nil},
},
},
wantLB: "lb-abc123",
wantSrv: []lbagent.ServerStatus{
{Backend: "", Server: "", Status: ""},
{Backend: "tg-1", Server: "", Status: "MAINT"},
{Backend: "", Server: "10.0.0.3:80", Status: ""},
},
},
}
report := in.toHealthReport()
assert.Equal(t, "lb-abc123", report.LBID)
assert.Len(t, report.Servers, 2)
assert.Equal(t, lbagent.ServerStatus{Backend: "tg-1", Server: "10.0.0.1:80", Status: "UP"}, report.Servers[0])
assert.Equal(t, lbagent.ServerStatus{Backend: "tg-2", Server: "10.0.0.2:80", Status: "DOWN"}, report.Servers[1])
}
func TestToHealthReport_NilLBID(t *testing.T) {
in := &LBAgentHeartbeatInput{
LBID: nil,
Servers: []*LBAgentServerStatus{
{Backend: aws.String("tg-1"), Server: aws.String("10.0.0.1:80"), Status: aws.String("UP")},
},
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
report := tt.in.toHealthReport()
assert.Equal(t, tt.wantLB, report.LBID)
if tt.wantSrv == nil {
assert.Empty(t, report.Servers)
} else {
assert.Equal(t, tt.wantSrv, report.Servers)
}
})
}
report := in.toHealthReport()
assert.Equal(t, "", report.LBID)
assert.Len(t, report.Servers, 1)
}
func TestToHealthReport_NilServers(t *testing.T) {
in := &LBAgentHeartbeatInput{
LBID: aws.String("lb-abc123"),
Servers: nil,
}
report := in.toHealthReport()
assert.Equal(t, "lb-abc123", report.LBID)
assert.Empty(t, report.Servers)
}
func TestToHealthReport_EmptyInput(t *testing.T) {
in := &LBAgentHeartbeatInput{}
report := in.toHealthReport()
assert.Equal(t, "", report.LBID)
assert.Empty(t, report.Servers)
}
func TestToHealthReport_NilServerFields(t *testing.T) {
in := &LBAgentHeartbeatInput{
LBID: aws.String("lb-abc123"),
Servers: []*LBAgentServerStatus{
{Backend: nil, Server: nil, Status: nil},
{Backend: aws.String("tg-1"), Server: nil, Status: aws.String("MAINT")},
{Backend: nil, Server: aws.String("10.0.0.3:80"), Status: nil},
},
}
report := in.toHealthReport()
assert.Len(t, report.Servers, 3)
assert.Equal(t, lbagent.ServerStatus{Backend: "", Server: "", Status: ""}, report.Servers[0])
assert.Equal(t, lbagent.ServerStatus{Backend: "tg-1", Server: "", Status: "MAINT"}, report.Servers[1])
assert.Equal(t, lbagent.ServerStatus{Backend: "", Server: "10.0.0.3:80", Status: ""}, report.Servers[2])
}
+102 -67
View File
@@ -8,6 +8,59 @@ import (
"github.com/stretchr/testify/require"
)
// extractAgentEnv parses the cloud-config user-data into the env-var map that
// cloud-init will write to /etc/conf.d/lb-agent. We don't pull in a YAML
// dependency just for tests — the format is stable enough that line-based
// parsing catches the things substring matching misses (misplaced keys,
// broken indentation, content that escaped the agent-conf block).
//
// Returns the parsed env map plus the `runcmd:` section's body (raw lines)
// so callers can assert on the launch command shape.
func extractAgentEnv(t *testing.T, ud string) (envs map[string]string, runcmd []string) {
t.Helper()
require.True(t, strings.HasPrefix(ud, "#cloud-config\n"),
"user-data must start with #cloud-config directive")
envs = map[string]string{}
const agentPathMarker = " - path: /etc/conf.d/lb-agent"
const contentMarker = " content: |"
const envIndent = " " // 6 spaces under `content: |`
lines := strings.Split(ud, "\n")
inAgentEnv := false
inRuncmd := false
for i := 0; i < len(lines); i++ {
line := lines[i]
switch {
case line == agentPathMarker:
require.Less(t, i+1, len(lines), "agent path entry truncated")
require.Equal(t, contentMarker, lines[i+1],
"agent path must be followed by `content: |` block")
inAgentEnv = true
i++ // skip the content marker
case inAgentEnv:
if !strings.HasPrefix(line, envIndent) {
inAgentEnv = false
i-- // re-process this line in case it starts another section
continue
}
kv := strings.TrimPrefix(line, envIndent)
k, v, ok := strings.Cut(kv, "=")
require.True(t, ok, "agent env line missing '=': %q", line)
envs[k] = v
case line == "runcmd:":
inRuncmd = true
case inRuncmd:
if !strings.HasPrefix(line, " ") {
inRuncmd = false
continue
}
runcmd = append(runcmd, line)
}
}
return envs, runcmd
}
func TestBuildLBArn(t *testing.T) {
arn := buildLBArn("us-east-1", "123456789012", "my-alb", "50dc6c495c0c9188", LoadBalancerTypeApplication)
assert.Equal(t, "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188", arn)
@@ -33,7 +86,12 @@ func TestBuildListenerArn_NLB(t *testing.T) {
assert.Equal(t, "arn:aws:elasticloadbalancing:eu-west-1:999888777666:listener/net/my-nlb/lbid123/listener456", arn)
}
func TestLbVMUserData_ContainsLBID(t *testing.T) {
// TestLbVMUserData_Structural parses the generated cloud-config and asserts
// the structure: agent env vars land in /etc/conf.d/lb-agent, runcmd starts
// lb-agent via OpenRC (not the bare binary), the CA cert section — owned by
// the instance service's cloud-init template — is absent, and no bootcmd is
// emitted in the single-node default.
func TestLbVMUserData_Structural(t *testing.T) {
svc := &ELBv2ServiceImpl{
GatewayURL: "https://192.168.1.33:9999",
SystemAccessKey: "AKIAIOSFODNN7EXAMPLE",
@@ -42,79 +100,56 @@ func TestLbVMUserData_ContainsLBID(t *testing.T) {
}
ud, err := svc.lbVMUserData("lb-abc123")
require.NoError(t, err)
assert.Contains(t, ud, "#cloud-config")
assert.Contains(t, ud, "write_files:")
assert.Contains(t, ud, "/etc/conf.d/lb-agent")
assert.Contains(t, ud, "LB_LB_ID=lb-abc123")
assert.Contains(t, ud, "LB_GATEWAY_URL=https://192.168.1.33:9999")
assert.Contains(t, ud, "LB_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE")
assert.Contains(t, ud, "LB_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
assert.Contains(t, ud, "LB_REGION=ap-southeast-2")
// NATS URL should no longer be present
assert.NotContains(t, ud, "NATS")
}
func TestLbVMUserData_WriteFilesThenRuncmd(t *testing.T) {
// Cloud-init guarantees write_files runs before runcmd. The agent is NOT
// enabled at boot via OpenRC — cloud-init is the sole trigger.
svc := &ELBv2ServiceImpl{
GatewayURL: "https://10.0.2.2:9999",
SystemAccessKey: "AKID",
SystemSecretKey: "SECRET",
envs, runcmd := extractAgentEnv(t, ud)
assert.Equal(t, map[string]string{
"LB_LB_ID": "lb-abc123",
"LB_GATEWAY_URL": "https://192.168.1.33:9999",
"LB_ACCESS_KEY": "AKIAIOSFODNN7EXAMPLE",
"LB_SECRET_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"LB_REGION": "ap-southeast-2",
}, envs)
for k := range envs {
assert.NotContains(t, k, "NATS", "NATS URL must not leak into agent config")
}
ud, err := svc.lbVMUserData("lb-test")
require.NoError(t, err)
assert.Contains(t, ud, "write_files:")
assert.Contains(t, ud, "/etc/conf.d/lb-agent")
assert.Contains(t, ud, "rc-service")
// Must not invoke the binary directly — OpenRC manages the process.
// Agent is launched via OpenRC, never as a bare binary path.
require.Len(t, runcmd, 1)
assert.Contains(t, runcmd[0], `[ "rc-service", "lb-agent", "start" ]`)
assert.NotContains(t, ud, "/usr/local/bin/lb-agent")
// write_files must appear before runcmd in the output
wfIdx := strings.Index(ud, "write_files:")
rcIdx := strings.Index(ud, "runcmd:")
assert.Greater(t, rcIdx, wfIdx, "write_files must precede runcmd")
}
func TestLbVMUserData_NoCACert(t *testing.T) {
// CA cert is injected by the instance service's cloud-init template,
// not by lbVMUserData — verify it's not duplicated here.
svc := &ELBv2ServiceImpl{
GatewayURL: "https://192.168.1.33:9999",
SystemAccessKey: "AKID",
SystemSecretKey: "SECRET",
}
ud, err := svc.lbVMUserData("lb-noca")
require.NoError(t, err)
// CA cert is injected upstream by the instance service's template.
assert.NotContains(t, ud, "ca_certs:")
// Single-node default: no MgmtRoute set means no bootcmd.
assert.NotContains(t, ud, "bootcmd:")
}
func TestLbVMUserData_EmptyGatewayURL(t *testing.T) {
svc := &ELBv2ServiceImpl{
SystemAccessKey: "AKID",
SystemSecretKey: "SECRET",
// TestLbVMUserData_MissingCredentials covers the three required-field error
// paths (gateway URL, access key, secret key) in one table-driven test.
func TestLbVMUserData_MissingCredentials(t *testing.T) {
tests := []struct {
name string
svc *ELBv2ServiceImpl
}{
{
name: "missing GatewayURL",
svc: &ELBv2ServiceImpl{SystemAccessKey: "AKID", SystemSecretKey: "SECRET"},
},
{
name: "missing SystemAccessKey",
svc: &ELBv2ServiceImpl{GatewayURL: "https://10.0.0.1:9999", SystemSecretKey: "SECRET"},
},
{
name: "missing SystemSecretKey",
svc: &ELBv2ServiceImpl{GatewayURL: "https://10.0.0.1:9999", SystemAccessKey: "AKID"},
},
}
_, err := svc.lbVMUserData("lb-test")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing system credentials")
}
func TestLbVMUserData_EmptyAccessKey(t *testing.T) {
svc := &ELBv2ServiceImpl{
GatewayURL: "https://10.0.0.1:9999",
SystemSecretKey: "SECRET",
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.svc.lbVMUserData("lb-test")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing system credentials")
})
}
_, err := svc.lbVMUserData("lb-test")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing system credentials")
}
func TestLbVMUserData_EmptySecretKey(t *testing.T) {
svc := &ELBv2ServiceImpl{
GatewayURL: "https://10.0.0.1:9999",
SystemAccessKey: "AKID",
}
_, err := svc.lbVMUserData("lb-test")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing system credentials")
}
+75 -165
View File
@@ -9,47 +9,10 @@ import (
"github.com/stretchr/testify/require"
)
// --- CreateTargetGroup: all health check fields + target type + tags ---
// Tests in this file cover paths NOT exercised by service_impl_test.go:
// filter-by-ARN family, missing-ARN error paths, RegisterTargets nil/not-found edges.
func TestCreateTargetGroup_AllHealthCheckFields(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("full-hc"),
Port: aws.Int64(8080),
Protocol: aws.String("HTTPS"),
VpcId: aws.String("vpc-123"),
TargetType: aws.String("ip"),
HealthCheckProtocol: aws.String("HTTPS"),
HealthCheckPort: aws.String("8443"),
HealthCheckPath: aws.String("/health"),
HealthCheckIntervalSeconds: aws.Int64(15),
HealthCheckTimeoutSeconds: aws.Int64(5),
HealthyThresholdCount: aws.Int64(3),
UnhealthyThresholdCount: aws.Int64(2),
Matcher: &elbv2.Matcher{HttpCode: aws.String("200")},
Tags: []*elbv2.Tag{
{Key: aws.String("env"), Value: aws.String("prod")},
{Key: aws.String("team"), Value: aws.String("platform")},
},
}, testAccountID)
require.NoError(t, err)
tg := out.TargetGroups[0]
assert.Equal(t, "ip", *tg.TargetType)
assert.Equal(t, "HTTPS", *tg.HealthCheckProtocol)
assert.Equal(t, "8443", *tg.HealthCheckPort)
assert.Equal(t, "/health", *tg.HealthCheckPath)
assert.Equal(t, int64(15), *tg.HealthCheckIntervalSeconds)
assert.Equal(t, int64(5), *tg.HealthCheckTimeoutSeconds)
assert.Equal(t, int64(3), *tg.HealthyThresholdCount)
assert.Equal(t, int64(2), *tg.UnhealthyThresholdCount)
assert.Equal(t, "200", *tg.Matcher.HttpCode)
assert.Equal(t, int64(8080), *tg.Port)
assert.Equal(t, "vpc-123", *tg.VpcId)
}
// --- DescribeLoadBalancers: ARN filter ---
// --- DescribeLoadBalancers: ARN filter (service_impl_test.go covers FilterByName) ---
func TestDescribeLoadBalancers_FilterByArn(t *testing.T) {
svc := setupTestService(t)
@@ -65,7 +28,7 @@ func TestDescribeLoadBalancers_FilterByArn(t *testing.T) {
assert.Equal(t, "lb-arn-a", *desc.LoadBalancers[0].LoadBalancerName)
}
// --- DescribeTargetGroups: by name ---
// --- DescribeTargetGroups: by name and by ARN (service_impl_test.go only covers FilterByLBArn) ---
func TestDescribeTargetGroups_FilterByName(t *testing.T) {
svc := setupTestService(t)
@@ -81,8 +44,6 @@ func TestDescribeTargetGroups_FilterByName(t *testing.T) {
assert.Equal(t, "tg-alpha", *desc.TargetGroups[0].TargetGroupName)
}
// --- DescribeTargetGroups: by ARN ---
func TestDescribeTargetGroups_FilterByArn(t *testing.T) {
svc := setupTestService(t)
@@ -97,7 +58,7 @@ func TestDescribeTargetGroups_FilterByArn(t *testing.T) {
assert.Equal(t, "tg-arn-a", *desc.TargetGroups[0].TargetGroupName)
}
// --- DescribeListeners: filter by listener ARN ---
// --- DescribeListeners: filter by listener ARN (service_impl_test.go covers FilterByLBArn) ---
func TestDescribeListeners_FilterByListenerArn(t *testing.T) {
svc := setupTestService(t)
@@ -124,7 +85,7 @@ func TestDescribeListeners_FilterByListenerArn(t *testing.T) {
assert.Equal(t, int64(80), *desc.Listeners[0].Port)
}
// --- RegisterTargets: nil target id skipped ---
// --- RegisterTargets edge cases ---
func TestRegisterTargets_NilTargetSkipped(t *testing.T) {
svc := setupTestService(t)
@@ -147,17 +108,12 @@ func TestRegisterTargets_NilTargetSkipped(t *testing.T) {
assert.Equal(t, "i-valid", *health.TargetHealthDescriptions[0].Target.Id)
}
// --- RegisterTargets / DeregisterTargets: missing ARN ---
func TestRegisterTargets_MissingArn(t *testing.T) {
func TestRegisterTargets_TGNotFound(t *testing.T) {
svc := setupTestService(t)
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{}, testAccountID)
assert.Error(t, err)
}
func TestDeregisterTargets_MissingArn(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DeregisterTargets(&elbv2.DeregisterTargetsInput{}, testAccountID)
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: aws.String("arn:nonexistent"),
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-abc")}},
}, testAccountID)
assert.Error(t, err)
}
@@ -169,26 +125,76 @@ func TestDeregisterTargets_TGNotFound(t *testing.T) {
assert.Error(t, err)
}
// --- CreateListener: custom protocol ---
// --- Missing-ARN error paths across the service surface ---
func TestCreateListener_CustomProtocol(t *testing.T) {
func TestMissingArnReturnsError(t *testing.T) {
svc := setupTestService(t)
lb, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-proto")}, testAccountID)
tg, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-proto")}, testAccountID)
tests := []struct {
name string
call func() error
}{
{
name: "DeleteLoadBalancer",
call: func() error {
_, err := svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{}, testAccountID)
return err
},
},
{
name: "DeleteTargetGroup",
call: func() error {
_, err := svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{}, testAccountID)
return err
},
},
{
name: "DeleteListener",
call: func() error {
_, err := svc.DeleteListener(&elbv2.DeleteListenerInput{}, testAccountID)
return err
},
},
{
name: "RegisterTargets",
call: func() error {
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{}, testAccountID)
return err
},
},
{
name: "DeregisterTargets",
call: func() error {
_, err := svc.DeregisterTargets(&elbv2.DeregisterTargetsInput{}, testAccountID)
return err
},
},
{
name: "DescribeTargetHealth",
call: func() error {
_, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{}, testAccountID)
return err
},
},
{
name: "CreateListener missing LB ARN",
call: func() error {
_, err := svc.CreateListener(&elbv2.CreateListenerInput{
DefaultActions: []*elbv2.Action{{Type: aws.String("forward")}},
}, testAccountID)
return err
},
},
}
out, err := svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
Protocol: aws.String("HTTPS"),
Port: aws.Int64(443),
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn}},
}, testAccountID)
require.NoError(t, err)
assert.Equal(t, "HTTPS", *out.Listeners[0].Protocol)
assert.Equal(t, int64(443), *out.Listeners[0].Port)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Error(t, tt.call())
})
}
}
// --- CreateListener: missing actions ---
// --- CreateListener: missing actions (separate from missing-ARN path) ---
func TestCreateListener_MissingActions(t *testing.T) {
svc := setupTestService(t)
@@ -199,99 +205,3 @@ func TestCreateListener_MissingActions(t *testing.T) {
}, testAccountID)
assert.Error(t, err)
}
// --- DeleteTargetGroup: missing ARN ---
func TestDeleteTargetGroup_MissingArn(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{}, testAccountID)
assert.Error(t, err)
}
// --- DeleteListener: missing ARN ---
func TestDeleteListener_MissingArn(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DeleteListener(&elbv2.DeleteListenerInput{}, testAccountID)
assert.Error(t, err)
}
// --- DescribeTargetHealth: missing ARN ---
func TestDescribeTargetHealth_MissingArn(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{}, testAccountID)
assert.Error(t, err)
}
// --- DescribeLoadBalancers: empty filters returns all ---
func TestDescribeLoadBalancers_All(t *testing.T) {
svc := setupTestService(t)
_, _ = svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-all-a")}, testAccountID)
_, _ = svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-all-b")}, testAccountID)
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, testAccountID)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(desc.LoadBalancers), 2)
}
// --- DescribeTargetGroups: all ---
func TestDescribeTargetGroups_All(t *testing.T) {
svc := setupTestService(t)
_, _ = svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-all-a")}, testAccountID)
_, _ = svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-all-b")}, testAccountID)
desc, err := svc.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{}, testAccountID)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(desc.TargetGroups), 2)
}
// --- DescribeListeners: all (no LB ARN filter) ---
func TestDescribeListeners_All(t *testing.T) {
svc := setupTestService(t)
lb, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-lall")}, testAccountID)
tg, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-lall")}, testAccountID)
_, _ = svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn}},
}, testAccountID)
desc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{}, testAccountID)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(desc.Listeners), 1)
}
// --- DeleteLoadBalancer: missing ARN ---
func TestDeleteLoadBalancer_MissingArn(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{}, testAccountID)
assert.Error(t, err)
}
// --- RegisterTargets: TG not found ---
func TestRegisterTargets_TGNotFound(t *testing.T) {
svc := setupTestService(t)
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: aws.String("arn:nonexistent"),
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-abc")}},
}, testAccountID)
assert.Error(t, err)
}
// --- CreateListener: missing LB ARN ---
func TestCreateListener_MissingLBArn(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateListener(&elbv2.CreateListenerInput{
DefaultActions: []*elbv2.Action{{Type: aws.String("forward")}},
}, testAccountID)
assert.Error(t, err)
}
+3 -141
View File
@@ -1,12 +1,11 @@
package handlers_elbv2
import (
"fmt"
"sync"
"testing"
)
// mockSystemInstanceLauncher implements SystemInstanceLauncher for tests.
// mockSystemInstanceLauncher implements SystemInstanceLauncher for tests in
// service_impl_test.go and service_impl_vpc_test.go.
type mockSystemInstanceLauncher struct {
mu sync.Mutex
launchCalls []*SystemInstanceInput
@@ -39,142 +38,5 @@ func (m *mockSystemInstanceLauncher) waitTerminate() {
}
}
func TestSystemInstanceInput_Fields(t *testing.T) {
input := &SystemInstanceInput{
InstanceType: "t3.nano",
ImageID: "ami-abc123",
SubnetID: "subnet-xyz",
UserData: "#!/bin/bash\necho hello",
ENIID: "eni-test123",
ENIMac: "02:00:00:11:22:33",
ENIIP: "10.0.1.5",
}
if input.InstanceType != "t3.nano" {
t.Errorf("InstanceType = %q, want %q", input.InstanceType, "t3.nano")
}
if input.ImageID != "ami-abc123" {
t.Errorf("ImageID = %q, want %q", input.ImageID, "ami-abc123")
}
if input.SubnetID != "subnet-xyz" {
t.Errorf("SubnetID = %q, want %q", input.SubnetID, "subnet-xyz")
}
if input.ENIID != "eni-test123" {
t.Errorf("ENIID = %q, want %q", input.ENIID, "eni-test123")
}
if input.ENIMac != "02:00:00:11:22:33" {
t.Errorf("ENIMac = %q, want %q", input.ENIMac, "02:00:00:11:22:33")
}
if input.ENIIP != "10.0.1.5" {
t.Errorf("ENIIP = %q, want %q", input.ENIIP, "10.0.1.5")
}
}
func TestMockSystemInstanceLauncher_Launch(t *testing.T) {
mock := &mockSystemInstanceLauncher{
launchResult: &SystemInstanceOutput{
InstanceID: "i-test123",
PrivateIP: "10.0.1.5",
},
}
input := &SystemInstanceInput{
InstanceType: "t3.nano",
ImageID: "ami-abc123",
SubnetID: "subnet-xyz",
UserData: "#!/bin/bash\necho hello",
}
out, err := mock.LaunchSystemInstance(input)
if err != nil {
t.Fatalf("LaunchSystemInstance: %v", err)
}
if out.InstanceID != "i-test123" {
t.Errorf("InstanceID = %q, want %q", out.InstanceID, "i-test123")
}
if out.PrivateIP != "10.0.1.5" {
t.Errorf("PrivateIP = %q, want %q", out.PrivateIP, "10.0.1.5")
}
if len(mock.launchCalls) != 1 {
t.Fatalf("expected 1 launch call, got %d", len(mock.launchCalls))
}
if mock.launchCalls[0].InstanceType != "t3.nano" {
t.Errorf("launch call InstanceType = %q, want %q", mock.launchCalls[0].InstanceType, "t3.nano")
}
}
func TestMockSystemInstanceLauncher_LaunchError(t *testing.T) {
mock := &mockSystemInstanceLauncher{
launchErr: fmt.Errorf("insufficient capacity"),
}
_, err := mock.LaunchSystemInstance(&SystemInstanceInput{
InstanceType: "t3.nano",
ImageID: "ami-abc123",
})
if err == nil {
t.Fatal("expected error from LaunchSystemInstance")
}
if err.Error() != "insufficient capacity" {
t.Errorf("error = %q, want %q", err.Error(), "insufficient capacity")
}
}
func TestMockSystemInstanceLauncher_Terminate(t *testing.T) {
mock := &mockSystemInstanceLauncher{}
err := mock.TerminateSystemInstance("i-test123")
if err != nil {
t.Fatalf("TerminateSystemInstance: %v", err)
}
if len(mock.terminateCalls) != 1 {
t.Fatalf("expected 1 terminate call, got %d", len(mock.terminateCalls))
}
if mock.terminateCalls[0] != "i-test123" {
t.Errorf("terminate call = %q, want %q", mock.terminateCalls[0], "i-test123")
}
}
func TestMockSystemInstanceLauncher_TerminateError(t *testing.T) {
mock := &mockSystemInstanceLauncher{
terminateErr: fmt.Errorf("instance not found"),
}
err := mock.TerminateSystemInstance("i-nonexistent")
if err == nil {
t.Fatal("expected error from TerminateSystemInstance")
}
}
func TestMockSystemInstanceLauncher_WithPreCreatedENI(t *testing.T) {
mock := &mockSystemInstanceLauncher{
launchResult: &SystemInstanceOutput{
InstanceID: "i-alb123",
PrivateIP: "10.201.1.6",
},
}
input := &SystemInstanceInput{
InstanceType: "t3.nano",
ImageID: "ami-abc123",
SubnetID: "subnet-vpc1",
UserData: "#!/bin/bash\napt-get install haproxy",
ENIID: "eni-alb456",
ENIMac: "02:00:00:aa:bb:cc",
ENIIP: "10.201.1.6",
}
out, err := mock.LaunchSystemInstance(input)
if err != nil {
t.Fatalf("LaunchSystemInstance: %v", err)
}
if out.InstanceID != "i-alb123" {
t.Errorf("InstanceID = %q, want %q", out.InstanceID, "i-alb123")
}
if mock.launchCalls[0].ENIID != "eni-alb456" {
t.Errorf("ENIID = %q, want %q", mock.launchCalls[0].ENIID, "eni-alb456")
}
}
// Verify the interface is satisfied by the mock at compile time.
// Compile-time check that the mock satisfies the production interface.
var _ SystemInstanceLauncher = (*mockSystemInstanceLauncher)(nil)
+2
View File
@@ -24,6 +24,8 @@ type NATSELBv2Service struct {
natsConn *nats.Conn
}
var _ ELBv2Service = (*NATSELBv2Service)(nil)
// NewNATSELBv2Service creates a new NATS-based ELBv2 service.
func NewNATSELBv2Service(conn *nats.Conn) ELBv2Service {
return &NATSELBv2Service{natsConn: conn}
@@ -1,16 +0,0 @@
package handlers_elbv2
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Verify NATSELBv2Service implements ELBv2Service at compile time.
var _ ELBv2Service = (*NATSELBv2Service)(nil)
func TestNewNATSELBv2Service_NilConn(t *testing.T) {
// Should create the service even with nil conn (conn is used at call time, not construction)
svc := NewNATSELBv2Service(nil)
assert.NotNil(t, svc)
}
+158 -161
View File
@@ -68,66 +68,162 @@ func newTestListener(id, lbArn string) *ListenerRecord {
}
}
// --- Load Balancer tests ---
func TestLoadBalancerStoreLifecycle(t *testing.T) {
t.Run("put and get", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutLoadBalancer(newTestLB("getput1", "lb-getput1")))
func TestPutAndGetLoadBalancer(t *testing.T) {
store := setupTestStore(t)
lb := newTestLB("abc123", "my-alb")
got, err := store.GetLoadBalancer("getput1")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "getput1", got.LoadBalancerID)
})
err := store.PutLoadBalancer(lb)
require.NoError(t, err)
t.Run("get not found", func(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetLoadBalancer("nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
})
got, err := store.GetLoadBalancer("abc123")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, lb.Name, got.Name)
assert.Equal(t, lb.LoadBalancerArn, got.LoadBalancerArn)
assert.Equal(t, lb.VpcId, got.VpcId)
assert.Equal(t, lb.Scheme, got.Scheme)
t.Run("delete removes record", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutLoadBalancer(newTestLB("del1", "lb-del1")))
require.NoError(t, store.DeleteLoadBalancer("del1"))
got, err := store.GetLoadBalancer("del1")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("delete idempotent", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.DeleteLoadBalancer("doesnt-exist"))
})
t.Run("list returns all", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutLoadBalancer(newTestLB("a", "lb-a")))
require.NoError(t, store.PutLoadBalancer(newTestLB("b", "lb-b")))
records, err := store.ListLoadBalancers()
require.NoError(t, err)
assert.Len(t, records, 2)
})
t.Run("list empty", func(t *testing.T) {
store := setupTestStore(t)
records, err := store.ListLoadBalancers()
require.NoError(t, err)
assert.Empty(t, records)
})
}
func TestGetLoadBalancer_NotFound(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetLoadBalancer("nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
func TestTargetGroupStoreLifecycle(t *testing.T) {
t.Run("put and get", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutTargetGroup(newTestTG("getput1", "tg-getput1")))
got, err := store.GetTargetGroup("getput1")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "getput1", got.TargetGroupID)
})
t.Run("get not found", func(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetTargetGroup("nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("delete removes record", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutTargetGroup(newTestTG("del1", "tg-del1")))
require.NoError(t, store.DeleteTargetGroup("del1"))
got, err := store.GetTargetGroup("del1")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("delete idempotent", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.DeleteTargetGroup("doesnt-exist"))
})
t.Run("list returns all", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutTargetGroup(newTestTG("a", "tg-a")))
require.NoError(t, store.PutTargetGroup(newTestTG("b", "tg-b")))
records, err := store.ListTargetGroups()
require.NoError(t, err)
assert.Len(t, records, 2)
})
t.Run("list empty", func(t *testing.T) {
store := setupTestStore(t)
records, err := store.ListTargetGroups()
require.NoError(t, err)
assert.Empty(t, records)
})
}
func TestStoreDeleteLoadBalancer(t *testing.T) {
store := setupTestStore(t)
lb := newTestLB("del123", "delete-me")
func TestListenerStoreLifecycle(t *testing.T) {
lbArn := "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/test/lb1"
require.NoError(t, store.PutLoadBalancer(lb))
require.NoError(t, store.DeleteLoadBalancer("del123"))
t.Run("put and get", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutListener(newTestListener("getput1", lbArn)))
got, err := store.GetLoadBalancer("del123")
require.NoError(t, err)
assert.Nil(t, got)
got, err := store.GetListener("getput1")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "getput1", got.ListenerID)
})
t.Run("get not found", func(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetListener("nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("delete removes record", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutListener(newTestListener("del1", lbArn)))
require.NoError(t, store.DeleteListener("del1"))
got, err := store.GetListener("del1")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("delete idempotent", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.DeleteListener("doesnt-exist"))
})
t.Run("list returns all", func(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutListener(newTestListener("a", lbArn)))
require.NoError(t, store.PutListener(newTestListener("b", lbArn)))
records, err := store.ListListeners()
require.NoError(t, err)
assert.Len(t, records, 2)
})
t.Run("list empty", func(t *testing.T) {
store := setupTestStore(t)
records, err := store.ListListeners()
require.NoError(t, err)
assert.Empty(t, records)
})
}
func TestDeleteLoadBalancer_Idempotent(t *testing.T) {
store := setupTestStore(t)
err := store.DeleteLoadBalancer("doesnt-exist")
require.NoError(t, err)
}
func TestListLoadBalancers(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutLoadBalancer(newTestLB("lb1", "alb-one")))
require.NoError(t, store.PutLoadBalancer(newTestLB("lb2", "alb-two")))
lbs, err := store.ListLoadBalancers()
require.NoError(t, err)
assert.Len(t, lbs, 2)
}
func TestListLoadBalancers_Empty(t *testing.T) {
store := setupTestStore(t)
lbs, err := store.ListLoadBalancers()
require.NoError(t, err)
assert.Nil(t, lbs)
}
// --- LB-specific lookups (no equivalent on TG/Listener) ---
func TestGetLoadBalancerByArn(t *testing.T) {
store := setupTestStore(t)
@@ -138,11 +234,8 @@ func TestGetLoadBalancerByArn(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, lb.Name, got.Name)
}
func TestGetLoadBalancerByArn_NotFound(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetLoadBalancerByArn("arn:nonexistent")
got, err = store.GetLoadBalancerByArn("arn:nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
}
@@ -158,53 +251,23 @@ func TestGetLoadBalancerByName(t *testing.T) {
assert.Equal(t, lb.LoadBalancerID, got.LoadBalancerID)
}
// --- Target Group tests ---
func TestPutAndGetTargetGroup(t *testing.T) {
func TestPutLoadBalancer_Update(t *testing.T) {
store := setupTestStore(t)
tg := newTestTG("tg123", "my-tg")
lb := newTestLB("upd123", "updatable")
require.NoError(t, store.PutLoadBalancer(lb))
require.NoError(t, store.PutTargetGroup(tg))
lb.State = StateFailed
lb.ENIs = []string{"eni-111", "eni-222"}
require.NoError(t, store.PutLoadBalancer(lb))
got, err := store.GetTargetGroup("tg123")
got, err := store.GetLoadBalancer("upd123")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, tg.Name, got.Name)
assert.Equal(t, tg.Protocol, got.Protocol)
assert.Equal(t, tg.Port, got.Port)
assert.Equal(t, tg.HealthCheck.Path, got.HealthCheck.Path)
assert.Equal(t, StateFailed, got.State)
assert.Equal(t, []string{"eni-111", "eni-222"}, got.ENIs)
}
func TestGetTargetGroup_NotFound(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetTargetGroup("nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
}
func TestStoreDeleteTargetGroup(t *testing.T) {
store := setupTestStore(t)
tg := newTestTG("tgdel", "delete-tg")
require.NoError(t, store.PutTargetGroup(tg))
require.NoError(t, store.DeleteTargetGroup("tgdel"))
got, err := store.GetTargetGroup("tgdel")
require.NoError(t, err)
assert.Nil(t, got)
}
func TestListTargetGroups(t *testing.T) {
store := setupTestStore(t)
require.NoError(t, store.PutTargetGroup(newTestTG("tg1", "tg-one")))
require.NoError(t, store.PutTargetGroup(newTestTG("tg2", "tg-two")))
require.NoError(t, store.PutTargetGroup(newTestTG("tg3", "tg-three")))
tgs, err := store.ListTargetGroups()
require.NoError(t, err)
assert.Len(t, tgs, 3)
}
// --- TG-specific lookups + targets ---
func TestGetTargetGroupByArn(t *testing.T) {
store := setupTestStore(t)
@@ -250,43 +313,7 @@ func TestTargetGroupWithTargets(t *testing.T) {
assert.Equal(t, "10.0.1.11", got.Targets[1].PrivateIP)
}
// --- Listener tests ---
func TestPutAndGetListener(t *testing.T) {
store := setupTestStore(t)
lbArn := "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/my-alb/lb123"
l := newTestListener("lst123", lbArn)
require.NoError(t, store.PutListener(l))
got, err := store.GetListener("lst123")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, ProtocolHTTP, got.Protocol)
assert.Equal(t, int64(80), got.Port)
assert.Len(t, got.DefaultActions, 1)
assert.Equal(t, ActionTypeForward, got.DefaultActions[0].Type)
}
func TestGetListener_NotFound(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetListener("nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
}
func TestStoreDeleteListener(t *testing.T) {
store := setupTestStore(t)
lbArn := "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/test/lb1"
l := newTestListener("lstdel", lbArn)
require.NoError(t, store.PutListener(l))
require.NoError(t, store.DeleteListener("lstdel"))
got, err := store.GetListener("lstdel")
require.NoError(t, err)
assert.Nil(t, got)
}
// --- Listener-specific lookups ---
func TestListListenersByLB(t *testing.T) {
store := setupTestStore(t)
@@ -304,12 +331,10 @@ func TestListListenersByLB(t *testing.T) {
require.NoError(t, store.PutListener(l2))
require.NoError(t, store.PutListener(l3))
// Should return only listeners for lbArn1
listeners, err := store.ListListenersByLB(lbArn1)
require.NoError(t, err)
assert.Len(t, listeners, 2)
// Should return only listener for lbArn2
listeners, err = store.ListListenersByLB(lbArn2)
require.NoError(t, err)
assert.Len(t, listeners, 1)
@@ -327,10 +352,9 @@ func TestGetListenerByArn(t *testing.T) {
assert.Equal(t, l.ListenerID, got.ListenerID)
}
// --- Cross-resource isolation tests ---
// --- Cross-resource isolation: shared IDs across record types must not collide ---
func TestResourceIsolation(t *testing.T) {
// Verify that LB, TG, and Listener records don't interfere with each other
store := setupTestStore(t)
lb := newTestLB("shared1", "alb-shared")
@@ -341,7 +365,6 @@ func TestResourceIsolation(t *testing.T) {
require.NoError(t, store.PutTargetGroup(tg))
require.NoError(t, store.PutListener(l))
// Each should be retrievable independently
gotLB, err := store.GetLoadBalancer("shared1")
require.NoError(t, err)
require.NotNil(t, gotLB)
@@ -357,14 +380,6 @@ func TestResourceIsolation(t *testing.T) {
require.NotNil(t, gotL)
assert.Equal(t, ProtocolHTTP, gotL.Protocol)
// List operations should return correct counts
lbs, _ := store.ListLoadBalancers()
assert.Len(t, lbs, 1)
tgs, _ := store.ListTargetGroups()
assert.Len(t, tgs, 1)
listeners, _ := store.ListListeners()
assert.Len(t, listeners, 1)
// Deleting one type should not affect others
require.NoError(t, store.DeleteLoadBalancer("shared1"))
gotTG, _ = store.GetTargetGroup("shared1")
@@ -373,23 +388,6 @@ func TestResourceIsolation(t *testing.T) {
assert.NotNil(t, gotL)
}
func TestPutLoadBalancer_Update(t *testing.T) {
store := setupTestStore(t)
lb := newTestLB("upd123", "updatable")
require.NoError(t, store.PutLoadBalancer(lb))
// Update state
lb.State = StateFailed
lb.ENIs = []string{"eni-111", "eni-222"}
require.NoError(t, store.PutLoadBalancer(lb))
got, err := store.GetLoadBalancer("upd123")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, StateFailed, got.State)
assert.Equal(t, []string{"eni-111", "eni-222"}, got.ENIs)
}
func TestTargetGroupsForLB(t *testing.T) {
store := setupTestStore(t)
@@ -398,7 +396,6 @@ func TestTargetGroupsForLB(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, tgs)
// Create LB, TG, and listener linking them
lb := newTestLB("tgflb1", "my-alb")
tg := newTestTG("tg001", "my-tg")
require.NoError(t, store.PutLoadBalancer(lb))
-60
View File
@@ -18,63 +18,3 @@ func TestDefaultHealthCheck(t *testing.T) {
assert.Equal(t, int64(DefaultUnhealthyThreshold), hc.UnhealthyThreshold)
assert.Equal(t, DefaultHealthCheckMatcher, hc.Matcher)
}
func TestHealthCheckConfig_CustomValues(t *testing.T) {
hc := HealthCheckConfig{
Protocol: ProtocolHTTPS,
Port: "8443",
Path: "/healthz",
IntervalSeconds: 10,
TimeoutSeconds: 3,
HealthyThreshold: 2,
UnhealthyThreshold: 3,
Matcher: "200-299",
}
assert.Equal(t, ProtocolHTTPS, hc.Protocol)
assert.Equal(t, "8443", hc.Port)
assert.Equal(t, "/healthz", hc.Path)
assert.Equal(t, int64(10), hc.IntervalSeconds)
}
func TestLoadBalancerRecord_Fields(t *testing.T) {
lb := LoadBalancerRecord{
Name: "my-alb",
Scheme: SchemeInternetFacing,
Type: LoadBalancerTypeApplication,
State: StateProvisioning,
SecurityGroups: []string{"sg-111", "sg-222"},
Subnets: []string{"subnet-aaa", "subnet-bbb"},
IPAddressType: IPAddressTypeIPv4,
}
assert.Equal(t, "internet-facing", lb.Scheme)
assert.Equal(t, "application", lb.Type)
assert.Equal(t, "provisioning", lb.State)
assert.Len(t, lb.SecurityGroups, 2)
assert.Len(t, lb.Subnets, 2)
}
func TestTarget_Fields(t *testing.T) {
target := Target{
Id: "i-abc123",
Port: 8080,
HealthState: TargetHealthHealthy,
HealthDesc: "Target is healthy",
PrivateIP: "10.0.1.50",
}
assert.Equal(t, "i-abc123", target.Id)
assert.Equal(t, int64(8080), target.Port)
assert.Equal(t, "healthy", target.HealthState)
}
func TestListenerAction_Fields(t *testing.T) {
action := ListenerAction{
Type: ActionTypeForward,
TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/abc123",
}
assert.Equal(t, "forward", action.Type)
assert.Contains(t, action.TargetGroupArn, "targetgroup")
}
-29
View File
@@ -153,28 +153,6 @@ func TestBuildArgs_NotVerbose(t *testing.T) {
}
}
func TestBuildArgs_PluginArgValues(t *testing.T) {
cfg := &NBDKitConfig{
Socket: "/tmp/nbd.sock",
PidFile: "/tmp/nbd.pid",
PluginPath: "/plugin.so",
Size: 0,
Volume: "",
CacheSize: 0,
ShardWAL: false,
}
args, err := cfg.buildArgs()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertContains(t, args, "size=0")
assertContains(t, args, "volume=")
assertContains(t, args, "cache_size=0")
assertContains(t, args, "shardwal=false")
}
func TestBuildArgs_TCPPortValue(t *testing.T) {
tests := []struct {
port int
@@ -267,13 +245,6 @@ func assertArgs(t *testing.T, expected, got []string) {
}
}
func assertContains(t *testing.T, args []string, val string) {
t.Helper()
if indexOf(args, val) < 0 {
t.Errorf("expected args to contain %q, got: %v", val, args)
}
}
func indexOf(args []string, val string) int {
for i, a := range args {
if a == val {
+39 -58
View File
@@ -67,69 +67,50 @@ func startJSNATSServer(t *testing.T) (*nats.Conn, nats.JetStreamContext) {
return nc, js
}
func TestWriteVersion_WritesKey(t *testing.T) {
// TestVersionStateMachine walks WriteVersion/ReadVersion through every state
// transition the migration helper guarantees:
// - unset → ReadVersion returns 0 with no error
// - first write persists the value
// - same version is a no-op (idempotent)
// - higher version replaces the stored value (upgrade)
// - lower version is a no-op (no downgrade)
//
// One bucket is reused across steps so each Write is exercised against the
// state the prior Write left behind — that's the only way to catch a
// regression that, say, made WriteVersion unconditionally overwrite.
func TestVersionStateMachine(t *testing.T) {
_, js := startJSNATSServer(t)
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-write-version"})
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-version-fsm"})
require.NoError(t, err)
err = WriteVersion(kv, 1)
// Unset → 0.
v, err := ReadVersion(kv)
require.NoError(t, err)
assert.Equal(t, 0, v, "ReadVersion on unset bucket")
steps := []struct {
name string
write int
want int // expected ReadVersion after the write
}{
{"first write persists", 1, 1},
{"same version is no-op", 1, 1},
{"higher version upgrades", 2, 2},
{"lower version is no-op (no downgrade)", 1, 2},
{"larger jump upgrades", 5, 5},
}
for _, step := range steps {
t.Run(step.name, func(t *testing.T) {
require.NoError(t, WriteVersion(kv, step.write))
v, err := ReadVersion(kv)
require.NoError(t, err)
assert.Equal(t, step.want, v)
})
}
// Round-trip the raw KV value to confirm the encoding is what readers
// outside this package would expect.
entry, err := kv.Get(VersionKey)
require.NoError(t, err)
assert.Equal(t, "1", string(entry.Value()))
}
func TestReadVersion_ReturnsZeroWhenUnset(t *testing.T) {
_, js := startJSNATSServer(t)
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-read-unset"})
require.NoError(t, err)
v, err := ReadVersion(kv)
require.NoError(t, err)
assert.Equal(t, 0, v)
}
func TestWriteVersion_IdempotentOnSameVersion(t *testing.T) {
_, js := startJSNATSServer(t)
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-idempotent"})
require.NoError(t, err)
require.NoError(t, WriteVersion(kv, 1))
require.NoError(t, WriteVersion(kv, 1))
v, err := ReadVersion(kv)
require.NoError(t, err)
assert.Equal(t, 1, v)
}
func TestWriteVersion_UpdatesOnHigherVersion(t *testing.T) {
_, js := startJSNATSServer(t)
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-upgrade"})
require.NoError(t, err)
require.NoError(t, WriteVersion(kv, 1))
require.NoError(t, WriteVersion(kv, 2))
v, err := ReadVersion(kv)
require.NoError(t, err)
assert.Equal(t, 2, v)
}
func TestWriteVersion_NoOpOnLowerVersion(t *testing.T) {
_, js := startJSNATSServer(t)
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-downgrade"})
require.NoError(t, err)
require.NoError(t, WriteVersion(kv, 3))
require.NoError(t, WriteVersion(kv, 1))
v, err := ReadVersion(kv)
require.NoError(t, err)
assert.Equal(t, 3, v)
assert.Equal(t, "5", string(entry.Value()))
}
+13 -21
View File
@@ -7,17 +7,18 @@ import (
"github.com/stretchr/testify/assert"
)
// Each wrapper has one or two clamp branches; tests cover the no-branch
// happy path and each branch. Boundary tests against MaxInt64/MaxUint8 etc.
// only exercise stdlib casts, so they are intentionally omitted.
func TestSafeInt64ToUint64(t *testing.T) {
tests := []struct {
name string
in int64
want uint64
}{
{"Zero", 0, 0},
{"Positive", 42, 42},
{"MaxInt64", math.MaxInt64, uint64(math.MaxInt64)},
{"Negative", -1, 0},
{"MinInt64", math.MinInt64, 0},
{"positive passes through", 42, 42},
{"negative clamps to 0", -1, 0},
}
for _, tt := range tests {
@@ -33,13 +34,9 @@ func TestSafeIntToUint8(t *testing.T) {
in int
want uint8
}{
{"Zero", 0, 0},
{"Normal", 128, 128},
{"Max uint8", 255, 255},
{"Above max", 256, 255},
{"Large", 1000, 255},
{"Negative", -1, 0},
{"Large negative", -100, 0},
{"in range passes through", 128, 128},
{"above max clamps", math.MaxUint8 + 1, math.MaxUint8},
{"negative clamps to 0", -1, 0},
}
for _, tt := range tests {
@@ -55,10 +52,8 @@ func TestSafeIntToUint64(t *testing.T) {
in int
want uint64
}{
{"Zero", 0, 0},
{"Positive", 42, 42},
{"Negative", -1, 0},
{"Large negative", -999, 0},
{"positive passes through", 42, 42},
{"negative clamps to 0", -1, 0},
}
for _, tt := range tests {
@@ -74,11 +69,8 @@ func TestSafeUint64ToInt64(t *testing.T) {
in uint64
want int64
}{
{"Zero", 0, 0},
{"Normal", 42, 42},
{"MaxInt64", uint64(math.MaxInt64), math.MaxInt64},
{"Above MaxInt64", uint64(math.MaxInt64) + 1, math.MaxInt64},
{"MaxUint64", math.MaxUint64, math.MaxInt64},
{"in range passes through", 42, 42},
{"above MaxInt64 clamps", uint64(math.MaxInt64) + 1, math.MaxInt64},
}
for _, tt := range tests {
+48 -46
View File
@@ -285,60 +285,62 @@ func TestExecute_NoGraphic(t *testing.T) {
}
func TestExecute_SerialSocketAndConsoleLog(t *testing.T) {
cfg := Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
SerialSocket: "/run/serial.sock",
ConsoleLogPath: "/var/log/console.log",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
t.Run("both set emits chardev and serial", func(t *testing.T) {
cfg := Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
SerialSocket: "/run/serial.sock",
ConsoleLogPath: "/var/log/console.log",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
cmd, err := cfg.Execute()
assert.NoError(t, err)
cmd, err := cfg.Execute()
assert.NoError(t, err)
args := cmd.Args[1:]
chardev := argValue(args, "-chardev")
assert.Contains(t, chardev, "socket,id=console0")
assert.Contains(t, chardev, "path=/run/serial.sock")
assert.Contains(t, chardev, "logfile=/var/log/console.log")
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
}
args := cmd.Args[1:]
chardev := argValue(args, "-chardev")
assert.Contains(t, chardev, "socket,id=console0")
assert.Contains(t, chardev, "path=/run/serial.sock")
assert.Contains(t, chardev, "logfile=/var/log/console.log")
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
})
func TestExecute_SerialSocketOnly(t *testing.T) {
cfg := Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
SerialSocket: "/run/serial.sock",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
// Boundary: the production guard requires BOTH fields. Flipping && to ||
// would silently emit invalid -chardev args; these subtests catch that.
t.Run("serial socket alone emits nothing", func(t *testing.T) {
cfg := Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
SerialSocket: "/run/serial.sock",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
cmd, err := cfg.Execute()
assert.NoError(t, err)
cmd, err := cfg.Execute()
assert.NoError(t, err)
args := cmd.Args[1:]
// When ConsoleLogPath is empty, -chardev and -serial should not appear
assert.Empty(t, argValue(args, "-chardev"))
assert.Empty(t, argValue(args, "-serial"))
}
args := cmd.Args[1:]
assert.Empty(t, argValue(args, "-chardev"))
assert.Empty(t, argValue(args, "-serial"))
})
func TestExecute_ConsoleLogPathOnly(t *testing.T) {
cfg := Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
ConsoleLogPath: "/var/log/console.log",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
t.Run("console log alone emits nothing", func(t *testing.T) {
cfg := Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
ConsoleLogPath: "/var/log/console.log",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
cmd, err := cfg.Execute()
assert.NoError(t, err)
cmd, err := cfg.Execute()
assert.NoError(t, err)
args := cmd.Args[1:]
// When SerialSocket is empty, -chardev and -serial should not appear
assert.Empty(t, argValue(args, "-chardev"))
assert.Empty(t, argValue(args, "-serial"))
args := cmd.Args[1:]
assert.Empty(t, argValue(args, "-chardev"))
assert.Empty(t, argValue(args, "-serial"))
})
}
func TestExecute_NetDevs(t *testing.T) {
+529
View File
@@ -0,0 +1,529 @@
#!/bin/bash
# run-reboot-e2e.sh — single-node host reboot resilience driver (cell #18).
#
# Runs from the GitHub Actions runner (NOT via ssh-and-run) so the script
# survives the systemctl-reboot-induced ssh disconnect mid-run. Each step that
# touches the cluster opens its own ssh session into the spinifex node.
#
# Required env:
# WAN_IP — single-node cluster's WAN IP
# SSH_KEY — path to ssh key the runner can use to reach tf-user@WAN_IP
#
# Sequence:
# 1. Pre-reboot: VPC + 2 app EC2s + internet-facing ALB; verify round-robin.
# 2. Snapshot pre-reboot state (instance IPs, ALB ENI MAC, ovn-nbctl show).
# 3. systemctl reboot the host; poll TCP/22 from the runner until SSH returns.
# 4. Wait for spinifex services + AWS gateway to respond.
# 5. Post-reboot assertions (each a separate pass/fail line).
# 6. Cleanup via EXIT trap.
#
# This cell is expected red until the OVN persistence fix lands — the
# `ovn-nbctl show` diff assertion is the canonical reproducer for that bug.
set -u
WAN_IP="${WAN_IP:?WAN_IP is required}"
SSH_KEY="${SSH_KEY:?SSH_KEY is required}"
SSH_KEY="${SSH_KEY/#\~/$HOME}"
REBOOT_WAIT_SECS="${REBOOT_WAIT_SECS:-300}"
DAEMON_READY_SECS="${DAEMON_READY_SECS:-180}"
INSTANCE_RUNNING_SECS="${INSTANCE_RUNNING_SECS:-120}"
LB_RECOVER_SECS="${LB_RECOVER_SECS:-180}"
NODE_KEY_PATH="\$HOME/reboot-e2e-key"
NODE_USERDATA_PATH="\$HOME/reboot-e2e-userdata.txt"
SSH_OPTS=(
-i "$SSH_KEY"
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o ConnectTimeout=10
-o LogLevel=ERROR
)
PASSED=0
FAILED=0
log() { echo "[$(date +%H:%M:%S)] $*"; }
pass() { echo " ✅ $1"; PASSED=$((PASSED + 1)); }
fail() { echo " ❌ $1"; FAILED=$((FAILED + 1)); }
node_ssh() { ssh "${SSH_OPTS[@]}" "tf-user@${WAN_IP}" "$@"; }
# Pipe a file's contents into a path on the node (tf-user-owned).
put_to_node() {
local dest="$1"
node_ssh "cat > $dest"
}
# Run an aws cli call on the node with the spinifex profile already configured.
node_aws_ec2() { node_ssh "AWS_PROFILE=spinifex aws ec2 $*"; }
node_aws_elbv2() { node_ssh "AWS_PROFILE=spinifex aws elbv2 $*"; }
# ==========================================================================
# Resource tracking for cleanup
# ==========================================================================
VPC_ID=""
SUBNET_ID=""
IGW_ID=""
APP_INSTANCE_IDS=()
ALB_LISTENER_ARN=""
ALB_LB_ARN=""
ALB_TG_ARN=""
cleanup() {
local exit_code=$?
echo ""
echo "=== Cleanup ==="
if ! node_ssh "true" 2>/dev/null; then
log "Cleanup skipped: node unreachable (exit=$exit_code)"
echo "Reboot E2E: $PASSED passed, $FAILED failed"
if [ $FAILED -gt 0 ] || [ $exit_code -ne 0 ]; then exit 1; fi
exit 0
fi
[ -n "$ALB_LISTENER_ARN" ] && node_aws_elbv2 "delete-listener --listener-arn $ALB_LISTENER_ARN" >/dev/null 2>&1 || true
[ -n "$ALB_LB_ARN" ] && node_aws_elbv2 "delete-load-balancer --load-balancer-arn $ALB_LB_ARN" >/dev/null 2>&1 || true
[ -n "$ALB_TG_ARN" ] && node_aws_elbv2 "delete-target-group --target-group-arn $ALB_TG_ARN" >/dev/null 2>&1 || true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
[ -n "$inst_id" ] && node_aws_ec2 "terminate-instances --instance-ids $inst_id" >/dev/null 2>&1 || true
done
if [ ${#APP_INSTANCE_IDS[@]} -gt 0 ]; then
for attempt in $(seq 1 30); do
local all_terminated=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
[ -z "$inst_id" ] && continue
local state
state=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].State.Name' --output text" 2>/dev/null || echo "terminated")
if [ "$state" != "terminated" ]; then all_terminated=false; break; fi
done
if [ "$all_terminated" = true ]; then break; fi
sleep 2
done
fi
node_aws_ec2 "delete-key-pair --key-name reboot-e2e-key" >/dev/null 2>&1 || true
node_ssh "rm -f $NODE_KEY_PATH $NODE_USERDATA_PATH" >/dev/null 2>&1 || true
if [ -n "$IGW_ID" ] && [ -n "$VPC_ID" ]; then
node_aws_ec2 "detach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID" >/dev/null 2>&1 || true
node_aws_ec2 "delete-internet-gateway --internet-gateway-id $IGW_ID" >/dev/null 2>&1 || true
fi
[ -n "$SUBNET_ID" ] && node_aws_ec2 "delete-subnet --subnet-id $SUBNET_ID" >/dev/null 2>&1 || true
[ -n "$VPC_ID" ] && node_aws_ec2 "delete-vpc --vpc-id $VPC_ID" >/dev/null 2>&1 || true
echo ""
echo "================================="
echo "Reboot E2E: $PASSED passed, $FAILED failed"
echo "================================="
if [ $FAILED -gt 0 ]; then exit 1; fi
exit $exit_code
}
trap cleanup EXIT
dump_diagnostics() {
log "--- spinifex service journals (last 100 lines each) ---"
for svc in spinifex-nats spinifex-predastore spinifex-viperblock \
spinifex-daemon spinifex-awsgw spinifex-vpcd \
ovn-controller ovs-vswitchd; do
echo "=== ${svc} ==="
node_ssh "sudo journalctl -u ${svc} --no-pager -n 100 2>/dev/null || true"
done
}
# ==========================================================================
# HTTP traffic burst from the spinifex node against an ALB URL.
# ==========================================================================
run_http_burst() {
local url="$1" label="$2" num="${3:-20}"
log "Sending $num HTTP requests to $url ($label)..."
local results
results=$(node_ssh "for i in \$(seq 1 $num); do curl -s --max-time 5 '$url/' 2>/dev/null; echo; done" 2>/dev/null || true)
declare -A counts
local total_ok=0
while IFS= read -r line; do
local inst
inst=$(echo "$line" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$inst" ]; then
counts[$inst]=$(( ${counts[$inst]:-0} + 1 ))
total_ok=$((total_ok + 1))
fi
done <<< "$results"
local unique=${#counts[@]}
echo " Distribution:"
for inst_id in "${!counts[@]}"; do echo " $inst_id: ${counts[$inst_id]} responses"; done
if [ "$unique" -ge 2 ]; then
pass "$label round-robin: $unique unique instances"
else
fail "$label round-robin: expected >=2 unique responders, got $unique"
fi
if [ "$total_ok" -ge $((num / 2)) ]; then
pass "$label success rate: $total_ok/$num"
else
fail "$label success rate: only $total_ok/$num"
fi
}
wait_for_lb_active() {
local lb_arn="$1" label="$2" timeout="${3:-270}"
log "Waiting for $label active (up to ${timeout}s)..."
local state=""
for attempt in $(seq 1 $((timeout / 3))); do
state=$(node_aws_elbv2 "describe-load-balancers --load-balancer-arns $lb_arn --query 'LoadBalancers[0].State.Code' --output text" 2>/dev/null || true)
if [ "$state" = "active" ]; then
pass "$label active"
return 0
fi
sleep 3
done
fail "$label did not reach active (last state: ${state:-unknown})"
return 1
}
wait_for_targets_healthy() {
local tg_arn="$1" expected="$2" label="$3" timeout="${4:-180}"
log "Waiting for $label targets healthy (up to ${timeout}s)..."
local healthy_count=0 total_count=0
for attempt in $(seq 1 $((timeout / 5))); do
local out
out=$(node_aws_elbv2 "describe-target-health --target-group-arn $tg_arn --output json" 2>/dev/null || echo '{}')
healthy_count=$(echo "$out" | jq '[.TargetHealthDescriptions[] | select(.TargetHealth.State == "healthy")] | length' 2>/dev/null || echo 0)
total_count=$(echo "$out" | jq '.TargetHealthDescriptions | length' 2>/dev/null || echo 0)
if [ "$healthy_count" -eq "$expected" ]; then
pass "$label: $expected/$expected targets healthy"
return 0
fi
sleep 5
done
fail "$label: only $healthy_count/$total_count healthy after ${timeout}s"
return 1
}
# ==========================================================================
# Phase 0: Prerequisites
# ==========================================================================
echo "================================="
echo "Reboot Resilience E2E (cell 18)"
echo "================================="
log "WAN_IP: $WAN_IP"
log "Verifying SSH to node..."
node_ssh "hostname" >/dev/null || { fail "ssh to $WAN_IP"; exit 1; }
pass "ssh to $WAN_IP"
log "Discovering instance type + AMI..."
INSTANCE_TYPE=$(node_aws_ec2 "describe-instance-types --query 'InstanceTypes[*].InstanceType' --output text" \
| tr ' \t' '\n\n' | grep -m1 'nano' || true)
if [ -z "$INSTANCE_TYPE" ]; then fail "no nano instance type available"; exit 1; fi
pass "instance type: $INSTANCE_TYPE"
ALL_IMAGES=$(node_aws_ec2 "describe-images --output json" 2>/dev/null)
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '[.Images[] | select(.Name | test("^ami-ubuntu"))][0].ImageId // empty')
if [ -z "$AMI_ID" ]; then
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '[.Images[] | select(.Name | test("alpine") | not)][0].ImageId // empty')
fi
if [ -z "$AMI_ID" ]; then AMI_ID=$(echo "$ALL_IMAGES" | jq -r '.Images[0].ImageId // empty'); fi
if [ -z "$AMI_ID" ]; then fail "no AMI found"; exit 1; fi
pass "AMI: $AMI_ID"
# Create key pair, capturing the private key material so we can ssh into guests
node_aws_ec2 "delete-key-pair --key-name reboot-e2e-key" >/dev/null 2>&1 || true
KEY_RESPONSE=$(node_aws_ec2 "create-key-pair --key-name reboot-e2e-key --output json" 2>/dev/null) \
|| { fail "create-key-pair"; exit 1; }
KEY_MATERIAL=$(echo "$KEY_RESPONSE" | jq -r '.KeyMaterial')
if [ -z "$KEY_MATERIAL" ] || [ "$KEY_MATERIAL" = "null" ]; then
fail "create-key-pair returned no KeyMaterial"; exit 1
fi
printf '%s' "$KEY_MATERIAL" | put_to_node "$NODE_KEY_PATH"
node_ssh "chmod 600 $NODE_KEY_PATH" || { fail "chmod key"; exit 1; }
pass "key pair: reboot-e2e-key"
# Stage user-data on the node (HTTP responder for round-robin verification)
APP_USER_DATA=$(cat <<'USERDATA'
#!/bin/bash
INSTANCE_ID=$(hostname)
mkdir -p /tmp/httpd && cd /tmp/httpd
echo "{\"instance_id\": \"${INSTANCE_ID}\"}" > index.html
nohup python3 -m http.server 80 --bind 0.0.0.0 > /dev/null 2>&1 &
USERDATA
)
printf '%s' "$APP_USER_DATA" | put_to_node "$NODE_USERDATA_PATH"
# ==========================================================================
# Phase 1: VPC + Subnet
# ==========================================================================
log "Creating VPC..."
VPC_ID=$(node_aws_ec2 "create-vpc --cidr-block 10.210.0.0/16 --output json" | jq -r '.Vpc.VpcId')
if [ -z "$VPC_ID" ] || [ "$VPC_ID" = "null" ]; then fail "create-vpc"; exit 1; fi
pass "create-vpc: $VPC_ID"
IGW_ID=$(node_aws_ec2 "create-internet-gateway --output json" | jq -r '.InternetGateway.InternetGatewayId')
node_aws_ec2 "attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID" >/dev/null \
|| { fail "attach-internet-gateway"; exit 1; }
pass "internet gateway: $IGW_ID (attached)"
SUBNET_ID=$(node_aws_ec2 "create-subnet --vpc-id $VPC_ID --cidr-block 10.210.1.0/24 --output json" | jq -r '.Subnet.SubnetId')
if [ -z "$SUBNET_ID" ] || [ "$SUBNET_ID" = "null" ]; then fail "create-subnet"; exit 1; fi
pass "create-subnet: $SUBNET_ID"
node_aws_ec2 "modify-subnet-attribute --subnet-id $SUBNET_ID --map-public-ip-on-launch" >/dev/null || true
# ==========================================================================
# Phase 2: Launch app EC2 instances
# ==========================================================================
log "Launching 2 app instances..."
for i in 1 2; do
INST=$(node_aws_ec2 "run-instances \
--image-id $AMI_ID \
--instance-type $INSTANCE_TYPE \
--key-name reboot-e2e-key \
--subnet-id $SUBNET_ID \
--user-data file://$NODE_USERDATA_PATH \
--output json" | jq -r '.Instances[0].InstanceId')
if [ -z "$INST" ] || [ "$INST" = "null" ]; then fail "run-instances (app $i)"; exit 1; fi
APP_INSTANCE_IDS+=("$INST")
log " app$i: $INST"
done
pass "launched ${#APP_INSTANCE_IDS[@]} app instances"
log "Waiting for instances to reach running..."
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
for attempt in $(seq 1 60); do
STATE=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].State.Name' --output text" 2>/dev/null)
if [ "$STATE" = "running" ]; then break; fi
if [ $attempt -eq 60 ]; then fail "instance $inst_id stuck in $STATE"; exit 1; fi
sleep 2
done
done
pass "all instances running"
declare -A PRE_INSTANCE_IPS
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
IP=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text" 2>/dev/null)
if [ -z "$IP" ] || [ "$IP" = "None" ]; then fail "$inst_id has no PrivateIpAddress"; exit 1; fi
PRE_INSTANCE_IPS[$inst_id]="$IP"
log " $inst_id -> $IP"
done
pass "all instances have private IPs"
# ==========================================================================
# Phase 3: ALB (internet-facing)
# ==========================================================================
log "Creating HTTP target group..."
ALB_TG_ARN=$(node_aws_elbv2 "create-target-group \
--name reboot-e2e-tg \
--protocol HTTP --port 80 \
--vpc-id $VPC_ID \
--health-check-path /index.html \
--health-check-interval-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--output json" | jq -r '.TargetGroups[0].TargetGroupArn')
if [ -z "$ALB_TG_ARN" ] || [ "$ALB_TG_ARN" = "null" ]; then fail "create-target-group"; exit 1; fi
pass "target group: $ALB_TG_ARN"
node_aws_elbv2 "register-targets --target-group-arn $ALB_TG_ARN \
--targets Id=${APP_INSTANCE_IDS[0]} Id=${APP_INSTANCE_IDS[1]}" >/dev/null \
|| { fail "register-targets"; exit 1; }
pass "registered 2 targets"
log "Creating internet-facing ALB..."
ALB_LB_ARN=$(node_aws_elbv2 "create-load-balancer \
--name reboot-e2e-alb \
--scheme internet-facing \
--subnets $SUBNET_ID \
--output json" | jq -r '.LoadBalancers[0].LoadBalancerArn')
if [ -z "$ALB_LB_ARN" ] || [ "$ALB_LB_ARN" = "null" ]; then fail "create-load-balancer"; exit 1; fi
pass "ALB: $ALB_LB_ARN"
ALB_LB_ID=$(echo "$ALB_LB_ARN" | sed 's|.*/||')
ALB_LISTENER_ARN=$(node_aws_elbv2 "create-listener \
--load-balancer-arn $ALB_LB_ARN \
--protocol HTTP --port 80 \
--default-actions Type=forward,TargetGroupArn=$ALB_TG_ARN \
--output json" | jq -r '.Listeners[0].ListenerArn')
if [ -z "$ALB_LISTENER_ARN" ] || [ "$ALB_LISTENER_ARN" = "null" ]; then fail "create-listener"; exit 1; fi
pass "listener: $ALB_LISTENER_ARN"
wait_for_lb_active "$ALB_LB_ARN" "ALB" || { dump_diagnostics; exit 1; }
ENI_OUT=$(node_aws_ec2 "describe-network-interfaces \
--filters Name=description,Values='ELB app/reboot-e2e-alb/${ALB_LB_ID}' \
--output json" 2>/dev/null)
ALB_PUBLIC_IP=$(echo "$ENI_OUT" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty')
ALB_ENI_ID=$(echo "$ENI_OUT" | jq -r '.NetworkInterfaces[0].NetworkInterfaceId // empty')
ALB_ENI_MAC=$(echo "$ENI_OUT" | jq -r '.NetworkInterfaces[0].MacAddress // empty')
if [ -z "$ALB_PUBLIC_IP" ] || [ "$ALB_PUBLIC_IP" = "null" ]; then fail "ALB has no public IP"; exit 1; fi
pass "ALB ENI $ALB_ENI_ID public IP: $ALB_PUBLIC_IP"
wait_for_targets_healthy "$ALB_TG_ARN" 2 "ALB" || { dump_diagnostics; exit 1; }
# ==========================================================================
# Phase 4: Pre-reboot traffic burst
# ==========================================================================
echo ""
echo "=== Phase 4: Pre-reboot traffic ==="
run_http_burst "http://${ALB_PUBLIC_IP}:80" "ALB pre-reboot"
# ==========================================================================
# Phase 5: Snapshot pre-reboot state
# ==========================================================================
echo ""
echo "=== Phase 5: Snapshot pre-reboot state ==="
PRE_OVN=$(node_ssh "sudo ovn-nbctl show 2>/dev/null" || echo "")
PRE_OVN_LS_COUNT=$(echo "$PRE_OVN" | grep -c '^switch ' || true)
PRE_OVN_PORT_COUNT=$(echo "$PRE_OVN" | grep -c '^ port ' || true)
log "ovn-nbctl: ${PRE_OVN_LS_COUNT} logical switches, ${PRE_OVN_PORT_COUNT} ports"
log "ALB ENI MAC: ${ALB_ENI_MAC:-<unknown>}"
pass "pre-reboot state snapshot captured"
# ==========================================================================
# Phase 6: Reboot the host
# ==========================================================================
echo ""
echo "=== Phase 6: systemctl reboot ==="
REBOOT_START=$(date +%s)
log "Issuing reboot via ssh (connection will drop)..."
node_ssh "sudo systemctl reboot" >/dev/null 2>&1 || true
# Give the host a moment to actually start shutting down before we begin polling
sleep 5
log "Polling TCP/22 (timeout ${REBOOT_WAIT_SECS}s)..."
HOST_UP=false
for attempt in $(seq 1 $((REBOOT_WAIT_SECS / 5))); do
if timeout 5 bash -c "</dev/tcp/${WAN_IP}/22" 2>/dev/null; then
if node_ssh "true" 2>/dev/null; then
HOST_UP=true
break
fi
fi
sleep 5
done
REBOOT_ELAPSED=$(( $(date +%s) - REBOOT_START ))
if [ "$HOST_UP" = true ]; then
pass "host SSH back after ${REBOOT_ELAPSED}s"
else
fail "host SSH did not come back within ${REBOOT_WAIT_SECS}s"
exit 1
fi
# ==========================================================================
# Phase 7: Wait for spinifex services + AWS gateway readiness
# ==========================================================================
echo ""
echo "=== Phase 7: Wait for spinifex readiness ==="
log "Service status:"
for svc in spinifex-daemon spinifex-predastore spinifex-viperblock \
spinifex-nats spinifex-awsgw spinifex-vpcd \
ovn-controller ovs-vswitchd ovn-northd; do
state=$(node_ssh "systemctl is-active $svc" 2>/dev/null || echo "n/a")
log " $svc: $state"
done
log "Polling describe-instance-types (timeout ${DAEMON_READY_SECS}s)..."
DAEMON_OK=false
for attempt in $(seq 1 $((DAEMON_READY_SECS / 5))); do
if node_aws_ec2 "describe-instance-types --query 'InstanceTypes[0].InstanceType' --output text" >/dev/null 2>&1; then
DAEMON_OK=true
break
fi
sleep 5
done
if [ "$DAEMON_OK" = true ]; then
pass "AWS gateway responding post-reboot"
else
fail "AWS gateway did not respond within ${DAEMON_READY_SECS}s"
dump_diagnostics
exit 1
fi
# ==========================================================================
# Phase 8: Post-reboot assertions
# ==========================================================================
echo ""
echo "=== Phase 8: Post-reboot assertions ==="
# 8.1: instances back to running with same IDs
log "Polling instance state (timeout ${INSTANCE_RUNNING_SECS}s)..."
ALL_RUNNING=false
for attempt in $(seq 1 $((INSTANCE_RUNNING_SECS / 5))); do
all=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
STATE=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].State.Name' --output text" 2>/dev/null || echo "missing")
if [ "$STATE" != "running" ]; then all=false; break; fi
done
if [ "$all" = true ]; then ALL_RUNNING=true; break; fi
sleep 5
done
if [ "$ALL_RUNNING" = true ]; then
pass "all instances back to running"
else
fail "instances did not all reach running within ${INSTANCE_RUNNING_SECS}s"
fi
# 8.2: private IPs preserved (IPAM persistence)
log "Verifying private IP persistence..."
IPS_PRESERVED=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
POST_IP=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text" 2>/dev/null)
if [ "$POST_IP" != "${PRE_INSTANCE_IPS[$inst_id]}" ]; then
fail "$inst_id IP drift: pre=${PRE_INSTANCE_IPS[$inst_id]} post=$POST_IP"
IPS_PRESERVED=false
fi
done
if [ "$IPS_PRESERVED" = true ]; then
pass "private IPs preserved across reboot"
fi
# 8.3: guest VMs actually rebooted (uptime check via two-hop ssh)
log "Verifying guest VMs rebooted (ssh into each guest from spinifex node)..."
GUESTS_REBOOTED=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
GUEST_IP="${PRE_INSTANCE_IPS[$inst_id]}"
GUEST_UPTIME=$(node_ssh "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -i $NODE_KEY_PATH ubuntu@${GUEST_IP} 'cat /proc/uptime'" 2>/dev/null | awk '{print int($1)}')
if [ -z "$GUEST_UPTIME" ]; then
fail "$inst_id ($GUEST_IP): guest ssh failed"
GUESTS_REBOOTED=false
elif [ "$GUEST_UPTIME" -gt 600 ]; then
fail "$inst_id ($GUEST_IP): uptime=${GUEST_UPTIME}s — guest did not reboot"
GUESTS_REBOOTED=false
else
log " $inst_id ($GUEST_IP): uptime=${GUEST_UPTIME}s ✓"
fi
done
if [ "$GUESTS_REBOOTED" = true ]; then
pass "all guests rebooted (uptime < 600s)"
fi
# 8.4: ALB back to active
wait_for_lb_active "$ALB_LB_ARN" "ALB post-reboot" "$LB_RECOVER_SECS" || true
# 8.5: targets healthy again
wait_for_targets_healthy "$ALB_TG_ARN" 2 "ALB post-reboot" "$LB_RECOVER_SECS" || true
# 8.6: re-run the traffic burst
echo ""
log "Re-running traffic burst against same ALB..."
run_http_burst "http://${ALB_PUBLIC_IP}:80" "ALB post-reboot"
# 8.7: ovn-nbctl diff (this is where the known persistence bug lands)
log "Diffing ovn-nbctl show against pre-reboot snapshot..."
POST_OVN=$(node_ssh "sudo ovn-nbctl show 2>/dev/null" || echo "")
POST_OVN_LS_COUNT=$(echo "$POST_OVN" | grep -c '^switch ' || true)
POST_OVN_PORT_COUNT=$(echo "$POST_OVN" | grep -c '^ port ' || true)
log "ovn-nbctl post: ${POST_OVN_LS_COUNT} logical switches, ${POST_OVN_PORT_COUNT} ports (pre: ${PRE_OVN_LS_COUNT} / ${PRE_OVN_PORT_COUNT})"
if [ "$POST_OVN_LS_COUNT" -eq "$PRE_OVN_LS_COUNT" ] && [ "$POST_OVN_PORT_COUNT" -eq "$PRE_OVN_PORT_COUNT" ]; then
pass "ovn-nbctl logical-switch + port counts match pre-reboot"
else
fail "ovn-nbctl drift: switches ${PRE_OVN_LS_COUNT}->${POST_OVN_LS_COUNT}, ports ${PRE_OVN_PORT_COUNT}->${POST_OVN_PORT_COUNT}"
fi
# Cleanup runs via the EXIT trap.