feat: ALB/ELBv2 service

This commit is contained in:
joshsiv-mulga
2026-03-26 21:38:14 +10:00
parent e5801aa6ba
commit f744aedede
55 changed files with 8169 additions and 32 deletions
+5 -1
View File
@@ -63,6 +63,10 @@ go_build:
@echo -e "\n....Building $(GO_PROJECT_NAME)"
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
build-alb-agent:
@echo -e "\n....Building alb-agent (static)"
CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/alb-agent cmd/alb-agent/main.go
go_run:
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
$(GOPATH)/bin/$(GO_PROJECT_NAME)
@@ -194,7 +198,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: build build-ui go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
.PHONY: build build-ui build-alb-agent go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
distro distro-amd64 distro-arm64 distro-clean
+62
View File
@@ -0,0 +1,62 @@
// alb-agent runs inside ALB VMs and manages HAProxy configuration.
//
// It connects to NATS, subscribes to config updates for its load balancer,
// writes HAProxy configs to disk, and reloads HAProxy on changes. It also
// responds to health pings so the daemon can verify the agent is alive.
//
// Usage:
//
// alb-agent --lb-id=lb-xxxxx --nats=nats://10.0.0.1:4222
//
// The LB ID defaults to the hostname (set by cloud-init to the LB ID).
// The NATS URL defaults to nats://127.0.0.1:4222.
package main
import (
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/mulgadc/spinifex/spinifex/albagent"
)
func main() {
var (
lbID string
natsURL string
)
hostname, _ := os.Hostname()
flag.StringVar(&lbID, "lb-id", hostname, "Load balancer ID (defaults to hostname)")
flag.StringVar(&natsURL, "nats", "nats://127.0.0.1:4222", "NATS server URL")
flag.Parse()
if lbID == "" {
slog.Error("--lb-id is required (or set hostname to the LB ID)")
os.Exit(1)
}
slog.Info("Starting ALB agent", "lbId", lbID, "nats", natsURL)
agent, err := albagent.New(lbID, natsURL)
if err != nil {
slog.Error("Failed to create agent", "err", err)
os.Exit(1)
}
if err := agent.Start(); err != nil {
slog.Error("Failed to start agent", "err", err)
os.Exit(1)
}
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigCh
slog.Info("Received signal, shutting down", "signal", sig)
agent.Stop()
}
+206
View File
@@ -0,0 +1,206 @@
#!/bin/bash
set -euo pipefail
# build-alb-image.sh — Build a minimal Alpine AMI for ALB VMs
#
# Creates a pre-baked Alpine Linux image with HAProxy + alb-agent installed,
# ready for import as a Spinifex AMI. ALB VMs boot in ~3s and need no internet
# access or package installation at runtime.
#
# Requirements: qemu-nbd, sudo (for mount/chroot), curl
# Usage: ./scripts/build-alb-image.sh [--import]
# --import Also import the image as an AMI via spx admin images import
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="/tmp/alb-image-build"
ALPINE_VERSION="3.21.6"
ALPINE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2"
ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/cloud/${ALPINE_IMAGE}"
OUTPUT_IMAGE="${BUILD_DIR}/alb-alpine.qcow2"
OUTPUT_RAW="${BUILD_DIR}/alb-alpine.raw"
NBD_DEV="/dev/nbd0"
MOUNT_DIR="${BUILD_DIR}/mnt"
ALB_AGENT_BIN="${PROJECT_DIR}/bin/alb-agent"
DO_IMPORT=false
if [[ "${1:-}" == "--import" ]]; then
DO_IMPORT=true
fi
cleanup() {
echo "Cleaning up..."
sudo umount "${MOUNT_DIR}" 2>/dev/null || true
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
echo "Done."
}
trap cleanup EXIT
echo "=== ALB Alpine Image Builder ==="
echo "Alpine version: ${ALPINE_VERSION}"
echo "Build dir: ${BUILD_DIR}"
echo ""
# Step 0: Check prerequisites
if [ ! -f "$ALB_AGENT_BIN" ]; then
echo "Building alb-agent (static)..."
(cd "$PROJECT_DIR" && CGO_ENABLED=0 go build -o bin/alb-agent cmd/alb-agent/main.go)
else
# Verify the existing binary is statically linked
if ! file "$ALB_AGENT_BIN" | grep -q "statically linked"; then
echo "Rebuilding alb-agent as static binary (Alpine uses musl)..."
(cd "$PROJECT_DIR" && CGO_ENABLED=0 go build -o bin/alb-agent cmd/alb-agent/main.go)
fi
fi
if ! command -v qemu-nbd &>/dev/null; then
echo "ERROR: qemu-nbd not found. Install qemu-utils."
exit 1
fi
mkdir -p "$BUILD_DIR" "$MOUNT_DIR"
# Step 1: Download Alpine cloud image
if [ -f "${BUILD_DIR}/${ALPINE_IMAGE}" ]; then
echo "Alpine image already downloaded."
else
echo "Downloading Alpine ${ALPINE_VERSION} cloud image..."
curl -L -o "${BUILD_DIR}/${ALPINE_IMAGE}" "$ALPINE_URL"
fi
# Step 2: Copy image for customization
echo "Copying image for customization..."
cp "${BUILD_DIR}/${ALPINE_IMAGE}" "$OUTPUT_IMAGE"
# Resize the image to 512M (Alpine cloud images are ~200MB, need room for packages)
qemu-img resize "$OUTPUT_IMAGE" 512M
# Step 3: Connect via qemu-nbd
echo "Connecting image via qemu-nbd..."
sudo modprobe nbd max_part=4 2>/dev/null || true
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
sudo qemu-nbd --connect="${NBD_DEV}" "$OUTPUT_IMAGE"
sleep 1
# Alpine cloud images have ext4 directly on the block device (no partition table).
# Resize the filesystem to fill the resized image.
echo "Resizing filesystem..."
sudo e2fsck -f -y "${NBD_DEV}" 2>/dev/null || true
sudo resize2fs "${NBD_DEV}" 2>/dev/null || true
# Step 4: Mount and customize
echo "Mounting root filesystem..."
sudo mount "${NBD_DEV}" "$MOUNT_DIR"
# Set up resolv.conf for DNS inside chroot
sudo cp /etc/resolv.conf "${MOUNT_DIR}/etc/resolv.conf"
echo "Installing packages in chroot..."
sudo chroot "$MOUNT_DIR" /bin/sh -c '
set -e
# Enable community repo (haproxy is in community)
sed -i "s|^#\(.*community\)|\1|" /etc/apk/repositories 2>/dev/null || true
# Ensure community repo is present
if ! grep -q "community" /etc/apk/repositories; then
MIRROR=$(grep "main" /etc/apk/repositories | head -1 | sed "s|/main|/community|")
echo "$MIRROR" >> /etc/apk/repositories
fi
apk update
apk add haproxy curl
# Enable haproxy to start at boot (but do not start it now)
rc-update add haproxy default 2>/dev/null || true
# Create haproxy config directory
mkdir -p /etc/haproxy
cat > /etc/haproxy/haproxy.cfg <<EOF
# Placeholder config — replaced by alb-agent on first config push
global
daemon
maxconn 256
defaults
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
frontend health
bind *:8405
http-request return status 200 content-type text/plain string "ok"
EOF
# Create alb-agent service directory
mkdir -p /etc/init.d
'
# Step 5: Copy alb-agent binary into the image
echo "Installing alb-agent binary..."
sudo cp "$ALB_AGENT_BIN" "${MOUNT_DIR}/usr/local/bin/alb-agent"
sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/alb-agent"
# Create OpenRC init script for alb-agent
sudo tee "${MOUNT_DIR}/etc/init.d/alb-agent" > /dev/null <<'INITSCRIPT'
#!/sbin/openrc-run
description="ALB NATS Config Agent"
command="/usr/local/bin/alb-agent"
command_args="--lb-id=${ALB_LB_ID:-unknown} --nats=${ALB_NATS_URL:-nats://10.0.2.2:4222}"
command_background=true
pidfile="/run/alb-agent.pid"
output_log="/var/log/alb-agent.log"
error_log="/var/log/alb-agent.log"
depend() {
need net
after firewall
}
INITSCRIPT
sudo chmod 755 "${MOUNT_DIR}/etc/init.d/alb-agent"
# Create a cloud-init module-final script hook that starts alb-agent with the
# correct LB ID and NATS URL (passed via cloud-init user-data env vars).
# Cloud-init on Alpine runs runcmd which we'll use to start the agent.
# Step 6: Clean up and unmount
echo "Cleaning up image..."
sudo chroot "$MOUNT_DIR" /bin/sh -c '
apk cache clean 2>/dev/null || true
rm -rf /var/cache/apk/* /tmp/*
'
# Restore original resolv.conf (cloud-init will set it on boot)
sudo rm -f "${MOUNT_DIR}/etc/resolv.conf"
echo "Unmounting..."
sudo umount "$MOUNT_DIR"
sudo qemu-nbd --disconnect "${NBD_DEV}"
# Step 7: Convert to raw for import
echo "Converting to raw format..."
qemu-img convert -f qcow2 -O raw "$OUTPUT_IMAGE" "$OUTPUT_RAW"
RAW_SIZE=$(stat -c%s "$OUTPUT_RAW")
echo ""
echo "=== Build complete ==="
echo " qcow2: $OUTPUT_IMAGE ($(du -h "$OUTPUT_IMAGE" | cut -f1))"
echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))"
echo ""
if [ "$DO_IMPORT" = true ]; then
echo "Importing as AMI..."
(cd "$PROJECT_DIR" && ./bin/spx admin images import \
--file "$OUTPUT_RAW" \
--distro alpine \
--version "${ALPINE_VERSION}-alb" \
--arch x86_64 \
--config "$HOME/spinifex/config/spinifex.toml")
else
echo "To import as AMI, run:"
echo " cd $PROJECT_DIR && ./bin/spx admin images import \\"
echo " --file $OUTPUT_RAW \\"
echo " --distro alpine --version ${ALPINE_VERSION}-alb --arch x86_64 \\"
echo " --config \$HOME/spinifex/config/spinifex.toml"
fi
+290
View File
@@ -0,0 +1,290 @@
// Package albagent implements the NATS config agent that runs inside ALB VMs.
// It subscribes to config updates, writes HAProxy configuration, and reloads
// the HAProxy process.
package albagent
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/nats-io/nats.go"
)
const (
// Default paths for HAProxy config and PID file.
DefaultConfigPath = "/etc/haproxy/haproxy.cfg"
DefaultPIDPath = "/run/haproxy.pid"
// NATS topic patterns.
configTopicPrefix = "elbv2.alb."
configTopicSuffix = ".config"
pingTopicSuffix = ".ping"
)
// Agent manages HAProxy configuration inside an ALB VM.
type Agent struct {
lbID string
natsURL string
configPath string
pidPath string
nc *nats.Conn
subs []*nats.Subscription
mu sync.Mutex
// For testing: override the reload function.
reloadFn func(configPath, pidPath string) error
}
// New creates a new ALB agent for the given load balancer.
func New(lbID, natsURL string) (*Agent, error) {
if lbID == "" {
return nil, fmt.Errorf("lbID is required")
}
if natsURL == "" {
natsURL = "nats://127.0.0.1:4222"
}
return &Agent{
lbID: lbID,
natsURL: natsURL,
configPath: DefaultConfigPath,
pidPath: DefaultPIDPath,
reloadFn: reloadHAProxy,
}, nil
}
// ConfigTopic returns the NATS topic for config updates.
func ConfigTopic(lbID string) string {
return configTopicPrefix + lbID + configTopicSuffix
}
// PingTopic returns the NATS topic for health pings.
func PingTopic(lbID string) string {
return configTopicPrefix + lbID + pingTopicSuffix
}
// Start connects to NATS and subscribes to config and ping topics.
func (a *Agent) Start() error {
nc, err := nats.Connect(a.natsURL,
nats.Name("alb-agent-"+a.lbID),
nats.MaxReconnects(-1),
nats.ReconnectWait(2*time.Second),
nats.DisconnectErrHandler(func(_ *nats.Conn, err error) {
slog.Warn("NATS disconnected", "err", err)
}),
nats.ReconnectHandler(func(_ *nats.Conn) {
slog.Info("NATS reconnected")
}),
)
if err != nil {
return fmt.Errorf("connect to NATS at %s: %w", a.natsURL, err)
}
a.nc = nc
// Subscribe to config updates
configSub, err := nc.Subscribe(ConfigTopic(a.lbID), a.handleConfig)
if err != nil {
nc.Close()
return fmt.Errorf("subscribe to config topic: %w", err)
}
// Subscribe to health pings (request/reply)
pingSub, err := nc.Subscribe(PingTopic(a.lbID), a.handlePing)
if err != nil {
nc.Close()
return fmt.Errorf("subscribe to ping topic: %w", err)
}
a.mu.Lock()
a.subs = append(a.subs, configSub, pingSub)
a.mu.Unlock()
slog.Info("Agent started",
"lbId", a.lbID,
"configTopic", ConfigTopic(a.lbID),
"pingTopic", PingTopic(a.lbID),
)
return nil
}
// Stop gracefully shuts down the agent.
func (a *Agent) Stop() {
a.mu.Lock()
defer a.mu.Unlock()
for _, sub := range a.subs {
if err := sub.Unsubscribe(); err != nil {
slog.Warn("Failed to unsubscribe", "err", err)
}
}
a.subs = nil
if a.nc != nil {
a.nc.Close()
a.nc = nil
}
slog.Info("Agent stopped", "lbId", a.lbID)
}
// handleConfig processes a config update message. The message payload is the
// raw HAProxy config text. It writes the config to disk and reloads HAProxy.
func (a *Agent) handleConfig(msg *nats.Msg) {
config := string(msg.Data)
if config == "" {
slog.Warn("Received empty config, ignoring")
a.respond(msg, "error", "empty config")
return
}
slog.Info("Received config update", "lbId", a.lbID, "size", len(config))
// Write config to disk
if err := WriteConfig(a.configPath, config); err != nil {
slog.Error("Failed to write config", "path", a.configPath, "err", err)
a.respond(msg, "error", err.Error())
return
}
// Reload HAProxy
if err := a.reloadFn(a.configPath, a.pidPath); err != nil {
slog.Error("Failed to reload HAProxy", "err", err)
a.respond(msg, "error", err.Error())
return
}
slog.Info("Config applied and HAProxy reloaded", "lbId", a.lbID)
a.respond(msg, "ok", "")
}
// PingResponse is the JSON response to a health ping.
type PingResponse struct {
Status string `json:"status"`
LBID string `json:"lb_id"`
HAProxy bool `json:"haproxy_running"`
ConfigAge int64 `json:"config_age_seconds"`
}
// handlePing responds to health check requests.
func (a *Agent) handlePing(msg *nats.Msg) {
haproxyRunning := isHAProxyRunning(a.pidPath)
configAge := configAgeSeconds(a.configPath)
resp := PingResponse{
Status: "ok",
LBID: a.lbID,
HAProxy: haproxyRunning,
ConfigAge: configAge,
}
data, _ := json.Marshal(resp)
if err := msg.Respond(data); err != nil {
slog.Warn("Failed to respond to ping", "err", err)
}
}
// respond sends a JSON reply to a NATS message if it has a reply subject.
func (a *Agent) respond(msg *nats.Msg, status, errMsg string) {
if msg.Reply == "" {
return
}
resp := map[string]string{"status": status}
if errMsg != "" {
resp["error"] = errMsg
}
data, _ := json.Marshal(resp)
if err := msg.Respond(data); err != nil {
slog.Warn("Failed to respond", "err", err)
}
}
// WriteConfig atomically writes an HAProxy config file.
// It writes to a temp file first, then renames for atomicity.
func WriteConfig(path, content string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
return fmt.Errorf("write temp config: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename config: %w", err)
}
return nil
}
// reloadHAProxy starts or reloads the HAProxy process.
// If HAProxy is running (PID file exists and process alive), it does a
// graceful reload with -sf. Otherwise it starts a fresh instance.
func reloadHAProxy(configPath, pidPath string) error {
// Ensure the stats socket directory exists (the config may reference
// /tmp/spinifex-haproxy/ which doesn't exist on fresh Alpine VMs).
_ = os.MkdirAll("/tmp/spinifex-haproxy", 0755)
oldPID := readPID(pidPath)
var cmd *exec.Cmd
if oldPID > 0 {
// Graceful reload: new worker starts, old workers finish in-flight requests
cmd = exec.Command("haproxy", "-f", configPath, "-p", pidPath, "-D", "-sf", strconv.Itoa(oldPID))
} else {
// Fresh start
cmd = exec.Command("haproxy", "-f", configPath, "-p", pidPath, "-D")
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("haproxy: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// readPID reads the HAProxy PID from the PID file. Returns 0 if unavailable.
func readPID(pidPath string) int {
data, err := os.ReadFile(pidPath)
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0
}
// Check if process is still alive
proc, err := os.FindProcess(pid)
if err != nil {
return 0
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
return 0
}
return pid
}
// isHAProxyRunning checks if HAProxy is running via its PID file.
func isHAProxyRunning(pidPath string) bool {
return readPID(pidPath) > 0
}
// configAgeSeconds returns how many seconds since the config file was last modified.
// Returns -1 if the file doesn't exist.
func configAgeSeconds(configPath string) int64 {
info, err := os.Stat(configPath)
if err != nil {
return -1
}
return int64(time.Since(info.ModTime()).Seconds())
}
+342
View File
@@ -0,0 +1,342 @@
package albagent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
natsserver "github.com/nats-io/nats-server/v2/server"
natstest "github.com/nats-io/nats-server/v2/test"
"github.com/nats-io/nats.go"
)
func startTestNATS(t *testing.T) *natsserver.Server {
t.Helper()
opts := natstest.DefaultTestOptions
opts.Port = -1 // random port
ns := natstest.RunServer(&opts)
t.Cleanup(ns.Shutdown)
return ns
}
func TestNew(t *testing.T) {
agent, err := New("lb-test123", "nats://localhost:4222")
if err != nil {
t.Fatalf("New: %v", err)
}
if agent.lbID != "lb-test123" {
t.Errorf("lbID = %q, want %q", agent.lbID, "lb-test123")
}
}
func TestNew_EmptyLBID(t *testing.T) {
_, err := New("", "nats://localhost:4222")
if err == nil {
t.Fatal("expected error for empty lbID")
}
}
func TestNew_DefaultNATSURL(t *testing.T) {
agent, err := New("lb-test", "")
if err != nil {
t.Fatalf("New: %v", err)
}
if agent.natsURL != "nats://127.0.0.1:4222" {
t.Errorf("natsURL = %q, want default", agent.natsURL)
}
}
func TestConfigTopic(t *testing.T) {
topic := ConfigTopic("lb-abc123")
if topic != "elbv2.alb.lb-abc123.config" {
t.Errorf("ConfigTopic = %q, want %q", topic, "elbv2.alb.lb-abc123.config")
}
}
func TestPingTopic(t *testing.T) {
topic := PingTopic("lb-abc123")
if topic != "elbv2.alb.lb-abc123.ping" {
t.Errorf("PingTopic = %q, want %q", topic, "elbv2.alb.lb-abc123.ping")
}
}
func TestWriteConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "haproxy.cfg")
content := "global\n log stdout\n"
if err := WriteConfig(path, content); err != nil {
t.Fatalf("WriteConfig: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != content {
t.Errorf("config = %q, want %q", string(data), content)
}
}
func TestWriteConfig_CreatesDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subdir", "haproxy.cfg")
if err := WriteConfig(path, "test"); err != nil {
t.Fatalf("WriteConfig: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("config file not created: %v", err)
}
}
func TestWriteConfig_Atomic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "haproxy.cfg")
// Write initial config
if err := WriteConfig(path, "initial"); err != nil {
t.Fatalf("WriteConfig initial: %v", err)
}
// Overwrite with new config
if err := WriteConfig(path, "updated"); err != nil {
t.Fatalf("WriteConfig updated: %v", err)
}
data, _ := os.ReadFile(path)
if string(data) != "updated" {
t.Errorf("config = %q, want %q", string(data), "updated")
}
// No temp file left behind
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Error("temp file should not exist after successful write")
}
}
func TestConfigAgeSeconds(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.cfg")
// Non-existent file
if age := configAgeSeconds(path); age != -1 {
t.Errorf("age of non-existent file = %d, want -1", age)
}
// Write file and check age
os.WriteFile(path, []byte("test"), 0o644)
age := configAgeSeconds(path)
if age < 0 || age > 2 {
t.Errorf("age = %d, expected 0-2 seconds", age)
}
}
func TestIsHAProxyRunning_NoPIDFile(t *testing.T) {
if isHAProxyRunning("/nonexistent/haproxy.pid") {
t.Error("expected false for non-existent PID file")
}
}
func TestReadPID_InvalidContent(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "haproxy.pid")
os.WriteFile(pidFile, []byte("not-a-number\n"), 0o644)
if pid := readPID(pidFile); pid != 0 {
t.Errorf("readPID = %d, want 0 for invalid content", pid)
}
}
func TestReadPID_DeadProcess(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "haproxy.pid")
// Use a very high PID that's unlikely to be running
os.WriteFile(pidFile, []byte("999999999\n"), 0o644)
if pid := readPID(pidFile); pid != 0 {
t.Errorf("readPID = %d, want 0 for dead process", pid)
}
}
func TestAgent_StartStop(t *testing.T) {
ns := startTestNATS(t)
agent, err := New("lb-test123", ns.ClientURL())
if err != nil {
t.Fatalf("New: %v", err)
}
if err := agent.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
// Verify agent is connected
if agent.nc == nil || !agent.nc.IsConnected() {
t.Fatal("agent not connected to NATS")
}
agent.Stop()
if agent.nc != nil {
t.Error("nc should be nil after Stop")
}
}
func TestAgent_HandleConfig(t *testing.T) {
ns := startTestNATS(t)
dir := t.TempDir()
agent, _ := New("lb-cfg123", ns.ClientURL())
agent.configPath = filepath.Join(dir, "haproxy.cfg")
agent.pidPath = filepath.Join(dir, "haproxy.pid")
// Mock reload to avoid needing real HAProxy
reloadCalled := false
agent.reloadFn = func(configPath, pidPath string) error {
reloadCalled = true
return nil
}
if err := agent.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer agent.Stop()
// Send a config update via a separate NATS connection
nc, err := nats.Connect(ns.ClientURL())
if err != nil {
t.Fatalf("connect: %v", err)
}
defer nc.Close()
haproxyConfig := "global\n log stdout\ndefaults\n mode http\n"
reply, err := nc.Request(ConfigTopic("lb-cfg123"), []byte(haproxyConfig), 2*time.Second)
if err != nil {
t.Fatalf("Request: %v", err)
}
// Check response
var resp map[string]string
json.Unmarshal(reply.Data, &resp)
if resp["status"] != "ok" {
t.Errorf("response status = %q, want %q", resp["status"], "ok")
}
// Verify config was written
data, err := os.ReadFile(agent.configPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != haproxyConfig {
t.Errorf("written config = %q, want %q", string(data), haproxyConfig)
}
if !reloadCalled {
t.Error("reload function was not called")
}
}
func TestAgent_HandleConfig_Empty(t *testing.T) {
ns := startTestNATS(t)
agent, _ := New("lb-empty", ns.ClientURL())
agent.reloadFn = func(_, _ string) error { return nil }
if err := agent.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer agent.Stop()
nc, _ := nats.Connect(ns.ClientURL())
defer nc.Close()
reply, err := nc.Request(ConfigTopic("lb-empty"), []byte(""), 2*time.Second)
if err != nil {
t.Fatalf("Request: %v", err)
}
var resp map[string]string
json.Unmarshal(reply.Data, &resp)
if resp["status"] != "error" {
t.Errorf("response status = %q, want %q for empty config", resp["status"], "error")
}
}
func TestAgent_HandlePing(t *testing.T) {
ns := startTestNATS(t)
dir := t.TempDir()
agent, _ := New("lb-ping123", ns.ClientURL())
agent.configPath = filepath.Join(dir, "haproxy.cfg")
agent.pidPath = filepath.Join(dir, "haproxy.pid")
if err := agent.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer agent.Stop()
nc, _ := nats.Connect(ns.ClientURL())
defer nc.Close()
reply, err := nc.Request(PingTopic("lb-ping123"), nil, 2*time.Second)
if err != nil {
t.Fatalf("Request: %v", err)
}
var resp PingResponse
if err := json.Unmarshal(reply.Data, &resp); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if resp.Status != "ok" {
t.Errorf("status = %q, want %q", resp.Status, "ok")
}
if resp.LBID != "lb-ping123" {
t.Errorf("lb_id = %q, want %q", resp.LBID, "lb-ping123")
}
if resp.HAProxy {
t.Error("haproxy_running should be false (no PID file)")
}
if resp.ConfigAge != -1 {
t.Errorf("config_age = %d, want -1 (no config file)", resp.ConfigAge)
}
}
func TestAgent_HandleConfig_ReloadError(t *testing.T) {
ns := startTestNATS(t)
dir := t.TempDir()
agent, _ := New("lb-err", ns.ClientURL())
agent.configPath = filepath.Join(dir, "haproxy.cfg")
agent.pidPath = filepath.Join(dir, "haproxy.pid")
agent.reloadFn = func(_, _ string) error {
return fmt.Errorf("haproxy binary not found")
}
if err := agent.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer agent.Stop()
nc, _ := nats.Connect(ns.ClientURL())
defer nc.Close()
reply, err := nc.Request(ConfigTopic("lb-err"), []byte("some config"), 2*time.Second)
if err != nil {
t.Fatalf("Request: %v", err)
}
var resp map[string]string
json.Unmarshal(reply.Data, &resp)
if resp["status"] != "error" {
t.Errorf("response status = %q, want %q", resp["status"], "error")
}
if resp["error"] == "" {
t.Error("expected error message in response")
}
}
+18 -3
View File
@@ -186,10 +186,20 @@ func setFieldValue(field reflect.Value, value string) error {
}
func setSliceField(field reflect.Value, params map[string]string, prefix string) error {
// Find all indexed items for this slice
// Find all indexed items for this slice.
// Supports both EC2-style (Prefix.N) and IAM/ELBv2-style (Prefix.member.N) formats.
indices := make(map[int]bool)
useMember := false
for key := range params {
if strings.HasPrefix(key, prefix+".") {
if strings.HasPrefix(key, prefix+".member.") {
parts := strings.Split(key[len(prefix)+len(".member."):], ".")
if len(parts) > 0 {
if idx, err := strconv.Atoi(parts[0]); err == nil {
indices[idx] = true
useMember = true
}
}
} else if strings.HasPrefix(key, prefix+".") {
parts := strings.Split(key[len(prefix)+1:], ".")
if len(parts) > 0 {
if idx, err := strconv.Atoi(parts[0]); err == nil {
@@ -221,7 +231,12 @@ func setSliceField(field reflect.Value, params map[string]string, prefix string)
}
elem := slice.Index(idx - 1)
indexPrefix := fmt.Sprintf("%s.%d", prefix, idx)
var indexPrefix string
if useMember {
indexPrefix = fmt.Sprintf("%s.member.%d", prefix, idx)
} else {
indexPrefix = fmt.Sprintf("%s.%d", prefix, idx)
}
// Handle different element types
switch elemType.Kind() {
+79
View File
@@ -5,6 +5,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/stretchr/testify/assert"
)
@@ -509,3 +510,81 @@ func TestQueryParamsToStruct_BlockDeviceMappingsWithIOPS(t *testing.T) {
assert.Equal(t, int64(500), aws.Int64Value(bdm.Ebs.Throughput))
assert.Equal(t, "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", aws.StringValue(bdm.Ebs.KmsKeyId))
}
// --- ELBv2 member-style list tests ---
func TestQueryParamsToStruct_ELBv2RegisterTargets(t *testing.T) {
// ELBv2 uses Targets.member.N.Id format (IAM-style query protocol)
args := map[string]string{
"Action": "RegisterTargets",
"TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123",
"Targets.member.1.Id": "i-1234567890abcdef0",
"Targets.member.2.Id": "i-0987654321fedcba0",
}
input := &elbv2.RegisterTargetsInput{}
err := QueryParamsToStruct(args, input)
assert.NoError(t, err)
assert.Equal(t, "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123", aws.StringValue(input.TargetGroupArn))
assert.Len(t, input.Targets, 2)
assert.Equal(t, "i-1234567890abcdef0", aws.StringValue(input.Targets[0].Id))
assert.Equal(t, "i-0987654321fedcba0", aws.StringValue(input.Targets[1].Id))
}
func TestQueryParamsToStruct_ELBv2RegisterTargetsWithPort(t *testing.T) {
args := map[string]string{
"Action": "RegisterTargets",
"TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123",
"Targets.member.1.Id": "i-1234567890abcdef0",
"Targets.member.1.Port": "8080",
}
input := &elbv2.RegisterTargetsInput{}
err := QueryParamsToStruct(args, input)
assert.NoError(t, err)
assert.Len(t, input.Targets, 1)
assert.Equal(t, "i-1234567890abcdef0", aws.StringValue(input.Targets[0].Id))
assert.Equal(t, int64(8080), aws.Int64Value(input.Targets[0].Port))
}
func TestQueryParamsToStruct_ELBv2CreateLoadBalancerMemberSubnets(t *testing.T) {
// ELBv2 uses Subnets.member.N format for string slices
args := map[string]string{
"Action": "CreateLoadBalancer",
"Name": "my-alb",
"Subnets.member.1": "subnet-abc123",
"Subnets.member.2": "subnet-def456",
}
input := &elbv2.CreateLoadBalancerInput{}
err := QueryParamsToStruct(args, input)
assert.NoError(t, err)
assert.Equal(t, "my-alb", aws.StringValue(input.Name))
assert.Len(t, input.Subnets, 2)
assert.Equal(t, "subnet-abc123", aws.StringValue(input.Subnets[0]))
assert.Equal(t, "subnet-def456", aws.StringValue(input.Subnets[1]))
}
func TestQueryParamsToStruct_ELBv2CreateListenerWithActions(t *testing.T) {
args := map[string]string{
"Action": "CreateListener",
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:loadbalancer/app/my-alb/lb-abc123",
"Protocol": "HTTP",
"Port": "80",
"DefaultActions.member.1.Type": "forward",
"DefaultActions.member.1.TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123",
}
input := &elbv2.CreateListenerInput{}
err := QueryParamsToStruct(args, input)
assert.NoError(t, err)
assert.Equal(t, "HTTP", aws.StringValue(input.Protocol))
assert.Equal(t, int64(80), aws.Int64Value(input.Port))
assert.Len(t, input.DefaultActions, 1)
assert.Equal(t, "forward", aws.StringValue(input.DefaultActions[0].Type))
assert.Equal(t, "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123", aws.StringValue(input.DefaultActions[0].TargetGroupArn))
}
+36
View File
@@ -432,6 +432,24 @@ var (
ErrorIAMInvalidInput = ErrorInvalidInput // alias for IAM usage
ErrorIAMMalformedPolicyDocument = "MalformedPolicyDocument"
ErrorAccessDenied = "AccessDenied"
// ELBv2-specific error codes
ErrorELBv2LoadBalancerNotFound = "LoadBalancerNotFound"
ErrorELBv2TargetGroupNotFound = "TargetGroupNotFound"
ErrorELBv2ListenerNotFound = "ListenerNotFound"
ErrorELBv2DuplicateLoadBalancer = "DuplicateLoadBalancerName"
ErrorELBv2DuplicateTargetGroup = "DuplicateTargetGroupName"
ErrorELBv2DuplicateListener = "DuplicateListener"
ErrorELBv2TooManyLoadBalancers = "TooManyLoadBalancers"
ErrorELBv2TooManyTargetGroups = "TooManyTargetGroups"
ErrorELBv2TooManyListeners = "TooManyListeners"
ErrorELBv2TooManyTargets = "TooManyRegistrationsForTargetId"
ErrorELBv2InvalidTarget = "InvalidTarget"
ErrorELBv2TargetGroupInUse = "ResourceInUse"
ErrorELBv2InvalidSecurityGroup = "InvalidSecurityGroup"
ErrorELBv2InvalidScheme = "InvalidScheme"
ErrorELBv2SubnetNotFound = "SubnetNotFound"
ErrorELBv2AvailabilityZoneNotSupported = "AvailabilityZoneNotSupported"
)
// ValidErrorCode returns the error code if it exists in ErrorLookup,
@@ -870,4 +888,22 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorIAMLimitExceeded: {HTTPCode: 409, Message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
ErrorIAMMalformedPolicyDocument: {HTTPCode: 400, Message: "The policy document is malformed."},
ErrorAccessDenied: {HTTPCode: 403, Message: "User is not authorized to perform this action."},
// ELBv2 error codes
ErrorELBv2LoadBalancerNotFound: {HTTPCode: 400, Message: "One or more load balancers not found."},
ErrorELBv2TargetGroupNotFound: {HTTPCode: 400, Message: "One or more target groups not found."},
ErrorELBv2ListenerNotFound: {HTTPCode: 400, Message: "One or more listeners not found."},
ErrorELBv2DuplicateLoadBalancer: {HTTPCode: 400, Message: "A load balancer with the same name already exists."},
ErrorELBv2DuplicateTargetGroup: {HTTPCode: 400, Message: "A target group with the same name already exists."},
ErrorELBv2DuplicateListener: {HTTPCode: 400, Message: "A listener with the specified port already exists on this load balancer."},
ErrorELBv2TooManyLoadBalancers: {HTTPCode: 400, Message: "You've reached the limit on the number of load balancers for your account."},
ErrorELBv2TooManyTargetGroups: {HTTPCode: 400, Message: "You've reached the limit on the number of target groups for your account."},
ErrorELBv2TooManyListeners: {HTTPCode: 400, Message: "You've reached the limit on the number of listeners per load balancer."},
ErrorELBv2TooManyTargets: {HTTPCode: 400, Message: "You've reached the limit on the number of times a target can be registered with a target group."},
ErrorELBv2InvalidTarget: {HTTPCode: 400, Message: "The specified target does not exist, is not in the same VPC as the target group, or has an unsupported instance type."},
ErrorELBv2TargetGroupInUse: {HTTPCode: 400, Message: "The target group is currently in use by a listener or rule."},
ErrorELBv2InvalidSecurityGroup: {HTTPCode: 400, Message: "The specified security group does not exist."},
ErrorELBv2InvalidScheme: {HTTPCode: 400, Message: "The specified scheme is not valid. Specify 'internet-facing' or 'internal'."},
ErrorELBv2SubnetNotFound: {HTTPCode: 400, Message: "The specified subnet does not exist."},
ErrorELBv2AvailabilityZoneNotSupported: {HTTPCode: 400, Message: "The specified Availability Zone is not supported."},
}
+17
View File
@@ -433,6 +433,23 @@ func TestErrorLookup(t *testing.T) {
{code: "LimitExceeded", http: 409, message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
{code: "MalformedPolicyDocument", http: 400, message: "The policy document is malformed."},
{code: "AccessDenied", http: 403, message: "User is not authorized to perform this action."},
// ELBv2 error codes
{code: "LoadBalancerNotFound", http: 400, message: "One or more load balancers not found."},
{code: "TargetGroupNotFound", http: 400, message: "One or more target groups not found."},
{code: "ListenerNotFound", http: 400, message: "One or more listeners not found."},
{code: "DuplicateLoadBalancerName", http: 400, message: "A load balancer with the same name already exists."},
{code: "DuplicateTargetGroupName", http: 400, message: "A target group with the same name already exists."},
{code: "DuplicateListener", http: 400, message: "A listener with the specified port already exists on this load balancer."},
{code: "TooManyLoadBalancers", http: 400, message: "You've reached the limit on the number of load balancers for your account."},
{code: "TooManyTargetGroups", http: 400, message: "You've reached the limit on the number of target groups for your account."},
{code: "TooManyListeners", http: 400, message: "You've reached the limit on the number of listeners per load balancer."},
{code: "TooManyRegistrationsForTargetId", http: 400, message: "You've reached the limit on the number of times a target can be registered with a target group."},
{code: "InvalidTarget", http: 400, message: "The specified target does not exist, is not in the same VPC as the target group, or has an unsupported instance type."},
{code: "ResourceInUse", http: 400, message: "The target group is currently in use by a listener or rule."},
{code: "InvalidSecurityGroup", http: 400, message: "The specified security group does not exist."},
{code: "InvalidScheme", http: 400, message: "The specified scheme is not valid. Specify 'internet-facing' or 'internal'."},
{code: "SubnetNotFound", http: 400, message: "The specified subnet does not exist."},
{code: "AvailabilityZoneNotSupported", http: 400, message: "The specified Availability Zone is not supported."},
}
if len(ErrorLookup) != len(expected) {
+71 -1
View File
@@ -45,6 +45,7 @@ import (
handlers_ec2_tags "github.com/mulgadc/spinifex/spinifex/handlers/ec2/tags"
handlers_ec2_volume "github.com/mulgadc/spinifex/spinifex/handlers/ec2/volume"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/qmp"
@@ -113,6 +114,7 @@ type Daemon struct {
placementGroupService *handlers_ec2_placementgroup.PlacementGroupServiceImpl
vpcService *handlers_ec2_vpc.VPCServiceImpl
eipService *handlers_ec2_eip.EIPServiceImpl
elbv2Service *handlers_elbv2.ELBv2ServiceImpl
routeTableService *handlers_ec2_routetable.RouteTableServiceImpl
natGatewayService *handlers_ec2_natgw.NatGatewayServiceImpl
externalIPAM *handlers_ec2_vpc.ExternalIPAM
@@ -453,6 +455,19 @@ func (d *Daemon) subscribeAll() error {
{"ec2.GetSerialConsoleAccessStatus", d.handleEC2GetSerialConsoleAccessStatus, "spinifex-workers"},
{"ec2.EnableSerialConsoleAccess", d.handleEC2EnableSerialConsoleAccess, "spinifex-workers"},
{"ec2.DisableSerialConsoleAccess", d.handleEC2DisableSerialConsoleAccess, "spinifex-workers"},
// ELBv2 operations
{"elbv2.CreateLoadBalancer", d.handleELBv2CreateLoadBalancer, "spinifex-workers"},
{"elbv2.DeleteLoadBalancer", d.handleELBv2DeleteLoadBalancer, "spinifex-workers"},
{"elbv2.DescribeLoadBalancers", d.handleELBv2DescribeLoadBalancers, "spinifex-workers"},
{"elbv2.CreateTargetGroup", d.handleELBv2CreateTargetGroup, "spinifex-workers"},
{"elbv2.DeleteTargetGroup", d.handleELBv2DeleteTargetGroup, "spinifex-workers"},
{"elbv2.DescribeTargetGroups", d.handleELBv2DescribeTargetGroups, "spinifex-workers"},
{"elbv2.RegisterTargets", d.handleELBv2RegisterTargets, "spinifex-workers"},
{"elbv2.DeregisterTargets", d.handleELBv2DeregisterTargets, "spinifex-workers"},
{"elbv2.DescribeTargetHealth", d.handleELBv2DescribeTargetHealth, "spinifex-workers"},
{"elbv2.CreateListener", d.handleELBv2CreateListener, "spinifex-workers"},
{"elbv2.DeleteListener", d.handleELBv2DeleteListener, "spinifex-workers"},
{"elbv2.DescribeListeners", d.handleELBv2DescribeListeners, "spinifex-workers"},
{fmt.Sprintf("spinifex.admin.%s.health", d.node), d.handleHealthCheck, ""},
{"spinifex.nodes.discover", d.handleNodeDiscover, ""},
{"spinifex.node.status", d.handleNodeStatus, ""},
@@ -642,6 +657,45 @@ func (d *Daemon) Start() error {
return fmt.Errorf("failed to initialize account settings service: %w", err)
}
d.elbv2Service, err = initServiceWithRetry("ELBv2 service", func() (*handlers_elbv2.ELBv2ServiceImpl, error) {
return handlers_elbv2.NewELBv2ServiceImplWithNATS(d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize ELBv2 service: %w", err)
}
if d.vpcService != nil {
d.elbv2Service.SetVPCService(d.vpcService)
}
// Wire ALB VM lifecycle: instance launcher + NATS URL for cloud-init.
// In dev mode (user-mode networking), VMs reach the host at 10.0.2.2
// (QEMU's default gateway). Replace 0.0.0.0 with this address.
d.elbv2Service.SetInstanceLauncher(d)
if d.config != nil && d.config.NATS.Host != "" {
natsHost := d.config.NATS.Host
if d.config.Daemon.DevNetworking && strings.HasPrefix(natsHost, "0.0.0.0") {
natsHost = strings.Replace(natsHost, "0.0.0.0", "10.0.2.2", 1)
}
d.elbv2Service.SetNATSURL("nats://" + natsHost)
}
// Discover system AMI for ALB VMs. Prefer an image with "alb" in the
// name (pre-baked Alpine with HAProxy), fall back to first available.
if d.imageService != nil {
imagesOut, imgErr := d.imageService.DescribeImages(&ec2.DescribeImagesInput{}, utils.GlobalAccountID)
if imgErr == nil && len(imagesOut.Images) > 0 {
systemAMI := aws.StringValue(imagesOut.Images[0].ImageId)
for _, img := range imagesOut.Images {
name := aws.StringValue(img.Name)
if strings.Contains(name, "alb") {
systemAMI = aws.StringValue(img.ImageId)
break
}
}
d.elbv2Service.SetSystemAMI(systemAMI)
slog.Info("System AMI set for ALB VMs", "amiId", systemAMI)
}
}
// Ensure default VPC exists for system and admin accounts
// (matches AWS: every account has a default VPC with IGW + default SG)
if d.vpcService != nil {
@@ -2249,8 +2303,24 @@ func (d *Daemon) StartInstance(instance *vm.VM) error {
if bindIP == "" || bindIP == "0.0.0.0" {
bindIP = "127.0.0.1"
}
netdevVal := fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort)
// Add extra hostfwd rules for system instances (e.g. ALB VMs forwarding HTTP ports)
if instance.ExtraHostfwd != nil {
for guestPort := range instance.ExtraHostfwd {
fwdAddr, fwdErr := viperblock.FindFreePort()
if fwdErr != nil {
slog.Warn("DEV_NETWORKING: failed to find free port for extra hostfwd", "guestPort", guestPort, "err", fwdErr)
continue
}
_, hostPort, _ := net.SplitHostPort(fwdAddr)
netdevVal += fmt.Sprintf(",hostfwd=tcp:%s:%s-:%d", bindIP, hostPort, guestPort)
hostPortInt, _ := strconv.Atoi(hostPort)
instance.ExtraHostfwd[guestPort] = hostPortInt
slog.Info("DEV_NETWORKING: extra hostfwd", "guestPort", guestPort, "hostPort", hostPort, "instanceId", instance.ID)
}
}
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
Value: fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
Value: netdevVal,
})
devMac := generateDevMAC(instance.ID)
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
+51
View File
@@ -0,0 +1,51 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleELBv2CreateLoadBalancer(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.CreateLoadBalancer)
}
func (d *Daemon) handleELBv2DeleteLoadBalancer(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeleteLoadBalancer)
}
func (d *Daemon) handleELBv2DescribeLoadBalancers(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancers)
}
func (d *Daemon) handleELBv2CreateTargetGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.CreateTargetGroup)
}
func (d *Daemon) handleELBv2DeleteTargetGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeleteTargetGroup)
}
func (d *Daemon) handleELBv2DescribeTargetGroups(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroups)
}
func (d *Daemon) handleELBv2RegisterTargets(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.RegisterTargets)
}
func (d *Daemon) handleELBv2DeregisterTargets(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeregisterTargets)
}
func (d *Daemon) handleELBv2DescribeTargetHealth(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeTargetHealth)
}
func (d *Daemon) handleELBv2CreateListener(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.CreateListener)
}
func (d *Daemon) handleELBv2DeleteListener(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeleteListener)
}
func (d *Daemon) handleELBv2DescribeListeners(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeListeners)
}
@@ -182,6 +182,12 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
eni := eniOut.NetworkInterface
instance.ENIId = *eni.NetworkInterfaceId
instance.ENIMac = *eni.MacAddress
// Mark ENI as attached to this instance so attachment.instance-id
// filter works (used by ELBv2 RegisterTargets to resolve target IPs).
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
slog.Warn("Failed to attach ENI to instance record", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
}
ec2Instance.SetPrivateIpAddress(*eni.PrivateIpAddress)
ec2Instance.SetSubnetId(*runInstancesInput.SubnetId)
ec2Instance.SetVpcId(*eni.VpcId)
+294
View File
@@ -0,0 +1,294 @@
package daemon
import (
"encoding/base64"
"fmt"
"log/slog"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
)
// Compile-time check that Daemon implements SystemInstanceLauncher.
var _ handlers_elbv2.SystemInstanceLauncher = (*Daemon)(nil)
// LaunchSystemInstance creates and starts a system-managed VM. The VM is owned
// by the system account (GlobalAccountID) and is not visible to customer
// DescribeInstances calls.
//
// This is the internal equivalent of RunInstances — it reuses the same
// instance service, resource manager, volume preparation, and QEMU launch
// path, but skips the NATS request/response envelope and key pair validation.
func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput) (*handlers_elbv2.SystemInstanceOutput, error) {
accountID := utils.GlobalAccountID
// Validate instance type
instanceType, exists := d.resourceMgr.instanceTypes[input.InstanceType]
if !exists {
return nil, fmt.Errorf("unknown instance type: %s", input.InstanceType)
}
// Validate AMI
if d.imageService == nil {
return nil, fmt.Errorf("image service not initialized")
}
if _, err := d.imageService.GetAMIConfig(input.ImageID); err != nil {
return nil, fmt.Errorf("AMI %s not found: %w", input.ImageID, err)
}
// Allocate resources
if err := d.resourceMgr.allocate(instanceType); err != nil {
return nil, fmt.Errorf("insufficient capacity for %s: %w", input.InstanceType, err)
}
// Build a RunInstancesInput for the instance service
runInput := &ec2.RunInstancesInput{
InstanceType: aws.String(input.InstanceType),
ImageId: aws.String(input.ImageID),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}
if input.SubnetID != "" {
runInput.SubnetId = aws.String(input.SubnetID)
}
if input.UserData != "" {
encoded := base64.StdEncoding.EncodeToString([]byte(input.UserData))
runInput.UserData = aws.String(encoded)
}
// Create VM via instance service
instance, ec2Instance, err := d.instanceService.RunInstance(runInput)
if err != nil {
d.resourceMgr.deallocate(instanceType)
return nil, fmt.Errorf("create instance: %w", err)
}
instance.AccountID = accountID
instance.Reservation = &ec2.Reservation{}
instance.Reservation.SetReservationId(utils.GenerateResourceID("r"))
instance.Reservation.SetOwnerId(accountID)
instance.Reservation.Instances = []*ec2.Instance{ec2Instance}
// Attach ENI — either use pre-created one or auto-create
privateIP := ""
if input.ENIID != "" {
// Use pre-created ENI (e.g. ALB ENI)
instance.ENIId = input.ENIID
instance.ENIMac = input.ENIMac
privateIP = input.ENIIP
ec2Instance.SetPrivateIpAddress(privateIP)
if input.SubnetID != "" {
ec2Instance.SetSubnetId(input.SubnetID)
}
// Mark ENI as attached to this instance
if d.vpcService != nil {
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
slog.Warn("LaunchSystemInstance: failed to attach ENI", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
}
}
} else if input.SubnetID != "" && d.vpcService != nil {
// Auto-create ENI in subnet
eniOut, eniErr := d.vpcService.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(input.SubnetID),
Description: aws.String("System interface for " + instance.ID),
}, accountID)
if eniErr != nil {
d.resourceMgr.deallocate(instanceType)
return nil, fmt.Errorf("create ENI: %w", eniErr)
}
eni := eniOut.NetworkInterface
instance.ENIId = *eni.NetworkInterfaceId
instance.ENIMac = *eni.MacAddress
privateIP = *eni.PrivateIpAddress
ec2Instance.SetPrivateIpAddress(privateIP)
ec2Instance.SetSubnetId(input.SubnetID)
ec2Instance.SetVpcId(*eni.VpcId)
// Mark ENI as attached
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
slog.Warn("LaunchSystemInstance: failed to attach auto-created ENI", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
}
}
// Add to daemon state so LaunchInstance can find it
d.Instances.Mu.Lock()
d.Instances.VMS[instance.ID] = instance
d.Instances.Mu.Unlock()
if err := d.WriteState(); err != nil {
slog.Warn("LaunchSystemInstance: failed to write state", "instanceId", instance.ID, "err", err)
}
// Subscribe to per-instance NATS topic for terminate commands
d.mu.Lock()
sub, subErr := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
if subErr != nil {
slog.Warn("LaunchSystemInstance: failed to subscribe to instance topic", "instanceId", instance.ID, "err", subErr)
} else {
d.natsSubscriptions[instance.ID] = sub
}
d.mu.Unlock()
// Pre-compute dev MAC for dual-NIC cloud-init
if d.config.Daemon.DevNetworking && instance.ENIId != "" {
instance.DevMAC = generateDevMAC(instance.ID)
}
// Store extra hostfwd ports (filled in by StartInstance with actual host ports)
if len(input.HostfwdPorts) > 0 {
instance.ExtraHostfwd = make(map[int]int, len(input.HostfwdPorts))
for _, port := range input.HostfwdPorts {
instance.ExtraHostfwd[port] = 0 // host port assigned in StartInstance
}
}
// Prepare volumes (root volume clone from AMI, cloud-init ISO, etc.)
volumeInfos, err := d.instanceService.GenerateVolumes(runInput, instance)
if err != nil {
d.cleanupFailedSystemInstance(instance, instanceType)
return nil, fmt.Errorf("generate volumes: %w", err)
}
instance.Instance.BlockDeviceMappings = make([]*ec2.InstanceBlockDeviceMapping, 0, len(volumeInfos))
for _, vi := range volumeInfos {
mapping := &ec2.InstanceBlockDeviceMapping{}
mapping.SetDeviceName(vi.DeviceName)
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
mapping.Ebs.SetVolumeId(vi.VolumeId)
mapping.Ebs.SetAttachTime(vi.AttachTime)
mapping.Ebs.SetDeleteOnTermination(vi.DeleteOnTermination)
mapping.Ebs.SetStatus("attached")
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
}
// Launch QEMU VM
if err := d.LaunchInstance(instance); err != nil {
d.cleanupFailedSystemInstance(instance, instanceType)
return nil, fmt.Errorf("launch instance: %w", err)
}
slog.Info("LaunchSystemInstance completed",
"instanceId", instance.ID,
"instanceType", input.InstanceType,
"privateIp", privateIP,
)
return &handlers_elbv2.SystemInstanceOutput{
InstanceID: instance.ID,
PrivateIP: privateIP,
HostfwdMap: instance.ExtraHostfwd,
}, nil
}
// TerminateSystemInstance stops and cleans up a system-managed VM.
func (d *Daemon) TerminateSystemInstance(instanceID string) error {
d.Instances.Mu.Lock()
instance, exists := d.Instances.VMS[instanceID]
d.Instances.Mu.Unlock()
if !exists {
return fmt.Errorf("instance %s not found", instanceID)
}
// Transition to shutting-down
if err := d.TransitionState(instance, vm.StateShuttingDown); err != nil {
return fmt.Errorf("transition to shutting-down: %w", err)
}
// Stop the VM (QEMU shutdown, volume unmount, tap cleanup)
if err := d.stopInstance(map[string]*vm.VM{instanceID: instance}, true); err != nil {
slog.Error("TerminateSystemInstance: stopInstance failed", "instanceId", instanceID, "err", err)
return fmt.Errorf("stop instance: %w", err)
}
if err := d.TransitionState(instance, vm.StateTerminated); err != nil {
slog.Warn("TerminateSystemInstance: failed to transition to terminated", "instanceId", instanceID, "err", err)
}
// Write to terminated KV bucket (auto-expires)
if d.jsManager != nil {
if err := d.jsManager.WriteTerminatedInstance(instanceID, instance); err != nil {
slog.Warn("TerminateSystemInstance: failed to write terminated state", "instanceId", instanceID, "err", err)
}
}
// Clean up local state
d.Instances.Mu.Lock()
delete(d.Instances.VMS, instanceID)
d.Instances.Mu.Unlock()
// Unsubscribe from per-instance NATS topic
d.mu.Lock()
if sub, ok := d.natsSubscriptions[instanceID]; ok {
if err := sub.Unsubscribe(); err != nil {
slog.Warn("TerminateSystemInstance: failed to unsubscribe", "instanceId", instanceID, "err", err)
}
delete(d.natsSubscriptions, instanceID)
}
d.mu.Unlock()
// Deallocate resources
if it, ok := d.resourceMgr.instanceTypes[instance.InstanceType]; ok {
d.resourceMgr.deallocate(it)
}
slog.Info("TerminateSystemInstance completed", "instanceId", instanceID)
return nil
}
// cleanupFailedSystemInstance handles cleanup when a system instance launch fails
// after partial setup (state added, volumes created, etc).
func (d *Daemon) cleanupFailedSystemInstance(instance *vm.VM, instanceType *ec2.InstanceTypeInfo) {
d.markInstanceFailed(instance, "system_instance_launch_failed")
d.resourceMgr.deallocate(instanceType)
// Clean up ENI if we auto-created one (not pre-created)
// Pre-created ENIs are managed by the caller (e.g. ELBv2 service)
if instance.ENIId != "" && d.vpcService != nil {
// Only clean up ENIs we created (description starts with "System interface")
result, err := d.vpcService.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(instance.ENIId)},
}, utils.GlobalAccountID)
if err == nil && len(result.NetworkInterfaces) > 0 {
desc := aws.StringValue(result.NetworkInterfaces[0].Description)
if len(desc) >= 16 && desc[:16] == "System interface" {
if _, delErr := d.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
NetworkInterfaceId: aws.String(instance.ENIId),
}, utils.GlobalAccountID); delErr != nil {
slog.Warn("Failed to cleanup system ENI", "eniId", instance.ENIId, "err", delErr)
}
}
}
}
}
// WaitForSystemInstance polls until the instance reaches running state or times out.
func (d *Daemon) WaitForSystemInstance(instanceID string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
d.Instances.Mu.Lock()
inst, exists := d.Instances.VMS[instanceID]
var status vm.InstanceState
if exists {
status = inst.Status
}
d.Instances.Mu.Unlock()
if !exists {
return fmt.Errorf("instance %s disappeared", instanceID)
}
switch status {
case vm.StateRunning:
return nil
case vm.StateError, vm.StateShuttingDown, vm.StateTerminated:
return fmt.Errorf("instance %s in terminal state: %s", instanceID, status)
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("instance %s did not reach running state within %s", instanceID, timeout)
}
@@ -0,0 +1,90 @@
package daemon
import (
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/vm"
)
func TestWaitForSystemInstance_AlreadyRunning(t *testing.T) {
d := &Daemon{
Instances: vm.Instances{
VMS: map[string]*vm.VM{
"i-test1": {ID: "i-test1", Status: vm.StateRunning},
},
},
}
err := d.WaitForSystemInstance("i-test1", 1*time.Second)
if err != nil {
t.Fatalf("expected no error for running instance, got: %v", err)
}
}
func TestWaitForSystemInstance_NotFound(t *testing.T) {
d := &Daemon{
Instances: vm.Instances{
VMS: map[string]*vm.VM{},
},
}
err := d.WaitForSystemInstance("i-nonexistent", 500*time.Millisecond)
if err == nil {
t.Fatal("expected error for missing instance")
}
}
func TestWaitForSystemInstance_ErrorState(t *testing.T) {
d := &Daemon{
Instances: vm.Instances{
VMS: map[string]*vm.VM{
"i-failed": {ID: "i-failed", Status: vm.StateError},
},
},
}
err := d.WaitForSystemInstance("i-failed", 1*time.Second)
if err == nil {
t.Fatal("expected error for failed instance")
}
}
func TestWaitForSystemInstance_TransitionsToRunning(t *testing.T) {
inst := &vm.VM{ID: "i-pending", Status: vm.StateProvisioning}
d := &Daemon{
Instances: vm.Instances{
VMS: map[string]*vm.VM{
"i-pending": inst,
},
},
}
// Transition to running after a short delay
go func() {
time.Sleep(200 * time.Millisecond)
d.Instances.Mu.Lock()
inst.Status = vm.StateRunning
d.Instances.Mu.Unlock()
}()
err := d.WaitForSystemInstance("i-pending", 2*time.Second)
if err != nil {
t.Fatalf("expected instance to reach running, got: %v", err)
}
}
func TestWaitForSystemInstance_Timeout(t *testing.T) {
d := &Daemon{
Instances: vm.Instances{
VMS: map[string]*vm.VM{
"i-stuck": {ID: "i-stuck", Status: vm.StateProvisioning},
},
},
}
err := d.WaitForSystemInstance("i-stuck", 600*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error")
}
}
+119
View File
@@ -0,0 +1,119 @@
package gateway
import (
"errors"
"io"
"log/slog"
"net/http"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awsec2query"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_elbv2 "github.com/mulgadc/spinifex/spinifex/gateway/elbv2"
"github.com/mulgadc/spinifex/spinifex/utils"
)
// ELBv2Handler processes parsed query args and returns XML response bytes.
type ELBv2Handler func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error)
// elbv2Handler creates a type-safe ELBv2Handler that allocates the typed input struct,
// parses query params into it, calls the handler, and marshals the output to XML.
// ELBv2 uses the IAM-style XML envelope: <ActionResponse><ActionResult>...</ActionResult></ActionResponse>
func elbv2Handler[In any](handler func(*In, *GatewayConfig, string) (any, error)) ELBv2Handler {
return func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
return nil, err
}
output, err := handler(input, gw, accountID)
if err != nil {
return nil, err
}
payload := utils.GenerateIAMXMLPayload(action, output)
xmlOutput, err := utils.MarshalToXML(payload)
if err != nil {
return nil, errors.New("failed to marshal response to XML")
}
return xmlOutput, nil
}
}
var elbv2Actions = map[string]ELBv2Handler{
"CreateLoadBalancer": elbv2Handler(func(input *elbv2.CreateLoadBalancerInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.CreateLoadBalancer(input, gw.NATSConn, accountID)
}),
"DeleteLoadBalancer": elbv2Handler(func(input *elbv2.DeleteLoadBalancerInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DeleteLoadBalancer(input, gw.NATSConn, accountID)
}),
"DescribeLoadBalancers": elbv2Handler(func(input *elbv2.DescribeLoadBalancersInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DescribeLoadBalancers(input, gw.NATSConn, accountID)
}),
"CreateTargetGroup": elbv2Handler(func(input *elbv2.CreateTargetGroupInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.CreateTargetGroup(input, gw.NATSConn, accountID)
}),
"DeleteTargetGroup": elbv2Handler(func(input *elbv2.DeleteTargetGroupInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DeleteTargetGroup(input, gw.NATSConn, accountID)
}),
"DescribeTargetGroups": elbv2Handler(func(input *elbv2.DescribeTargetGroupsInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DescribeTargetGroups(input, gw.NATSConn, accountID)
}),
"RegisterTargets": elbv2Handler(func(input *elbv2.RegisterTargetsInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.RegisterTargets(input, gw.NATSConn, accountID)
}),
"DeregisterTargets": elbv2Handler(func(input *elbv2.DeregisterTargetsInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DeregisterTargets(input, gw.NATSConn, accountID)
}),
"DescribeTargetHealth": elbv2Handler(func(input *elbv2.DescribeTargetHealthInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DescribeTargetHealth(input, gw.NATSConn, accountID)
}),
"CreateListener": elbv2Handler(func(input *elbv2.CreateListenerInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.CreateListener(input, gw.NATSConn, accountID)
}),
"DeleteListener": elbv2Handler(func(input *elbv2.DeleteListenerInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DeleteListener(input, gw.NATSConn, accountID)
}),
"DescribeListeners": elbv2Handler(func(input *elbv2.DescribeListenersInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_elbv2.DescribeListeners(input, gw.NATSConn, accountID)
}),
}
func (gw *GatewayConfig) ELBv2_Request(w http.ResponseWriter, r *http.Request) error {
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("Failed to read ELBv2 request body", "error", err)
return errors.New(awserrors.ErrorInternalError)
}
queryArgs := ParseAWSQueryArgs(string(body))
action := queryArgs["Action"]
handler, ok := elbv2Actions[action]
if !ok {
return errors.New(awserrors.ErrorInvalidAction)
}
if err := gw.checkPolicy(r, "elasticloadbalancing", action); err != nil {
return err
}
if gw.NATSConn == nil {
return errors.New(awserrors.ErrorServerInternal)
}
accountID, _ := r.Context().Value(ctxAccountID).(string)
if accountID == "" {
slog.Error("ELBv2_Request: no account ID in auth context")
return errors.New(awserrors.ErrorServerInternal)
}
xmlOutput, err := handler(action, queryArgs, gw, accountID)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(xmlOutput); err != nil {
slog.Error("Failed to write ELBv2 response", "err", err)
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// CreateListener handles the ELBv2 CreateListener API call.
func CreateListener(input *elbv2.CreateListenerInput, natsConn *nats.Conn, accountID string) (elbv2.CreateListenerOutput, error) {
var output elbv2.CreateListenerOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
if len(input.DefaultActions) == 0 {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.CreateListener(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,30 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// CreateLoadBalancer handles the ELBv2 CreateLoadBalancer API call.
func CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, natsConn *nats.Conn, accountID string) (elbv2.CreateLoadBalancerOutput, error) {
var output elbv2.CreateLoadBalancerOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.Name == nil || *input.Name == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.CreateLoadBalancer(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,30 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// CreateTargetGroup handles the ELBv2 CreateTargetGroup API call.
func CreateTargetGroup(input *elbv2.CreateTargetGroupInput, natsConn *nats.Conn, accountID string) (elbv2.CreateTargetGroupOutput, error) {
var output elbv2.CreateTargetGroupOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.Name == nil || *input.Name == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.CreateTargetGroup(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
+30
View File
@@ -0,0 +1,30 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DeleteListener handles the ELBv2 DeleteListener API call.
func DeleteListener(input *elbv2.DeleteListenerInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteListenerOutput, error) {
var output elbv2.DeleteListenerOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.ListenerArn == nil || *input.ListenerArn == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DeleteListener(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,30 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DeleteLoadBalancer handles the ELBv2 DeleteLoadBalancer API call.
func DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteLoadBalancerOutput, error) {
var output elbv2.DeleteLoadBalancerOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DeleteLoadBalancer(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,30 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DeleteTargetGroup handles the ELBv2 DeleteTargetGroup API call.
func DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteTargetGroupOutput, error) {
var output elbv2.DeleteTargetGroupOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DeleteTargetGroup(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,33 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DeregisterTargets handles the ELBv2 DeregisterTargets API call.
func DeregisterTargets(input *elbv2.DeregisterTargetsInput, natsConn *nats.Conn, accountID string) (elbv2.DeregisterTargetsOutput, error) {
var output elbv2.DeregisterTargetsOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
if len(input.Targets) == 0 {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DeregisterTargets(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,27 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DescribeListeners handles the ELBv2 DescribeListeners API call.
func DescribeListeners(input *elbv2.DescribeListenersInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeListenersOutput, error) {
var output elbv2.DescribeListenersOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DescribeListeners(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,27 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DescribeLoadBalancers handles the ELBv2 DescribeLoadBalancers API call.
func DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeLoadBalancersOutput, error) {
var output elbv2.DescribeLoadBalancersOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DescribeLoadBalancers(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,27 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DescribeTargetGroups handles the ELBv2 DescribeTargetGroups API call.
func DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeTargetGroupsOutput, error) {
var output elbv2.DescribeTargetGroupsOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DescribeTargetGroups(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,30 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// DescribeTargetHealth handles the ELBv2 DescribeTargetHealth API call.
func DescribeTargetHealth(input *elbv2.DescribeTargetHealthInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeTargetHealthOutput, error) {
var output elbv2.DescribeTargetHealthOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.DescribeTargetHealth(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
+33
View File
@@ -0,0 +1,33 @@
package gateway_elbv2
import (
"errors"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/nats-io/nats.go"
)
// RegisterTargets handles the ELBv2 RegisterTargets API call.
func RegisterTargets(input *elbv2.RegisterTargetsInput, natsConn *nats.Conn, accountID string) (elbv2.RegisterTargetsOutput, error) {
var output elbv2.RegisterTargetsOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
if len(input.Targets) == 0 {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
result, err := svc.RegisterTargets(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,146 @@
package gateway_elbv2
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
)
// These tests validate input validation in the gateway layer.
// They do not require a NATS connection since validation happens before the NATS call.
func TestCreateLoadBalancer_NilInput(t *testing.T) {
_, err := CreateLoadBalancer(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestCreateLoadBalancer_MissingName(t *testing.T) {
_, err := CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestCreateLoadBalancer_EmptyName(t *testing.T) {
_, err := CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String(""),
}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDeleteLoadBalancer_NilInput(t *testing.T) {
_, err := DeleteLoadBalancer(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestDeleteLoadBalancer_MissingArn(t *testing.T) {
_, err := DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDescribeLoadBalancers_NilInput(t *testing.T) {
_, err := DescribeLoadBalancers(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestCreateTargetGroup_NilInput(t *testing.T) {
_, err := CreateTargetGroup(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestCreateTargetGroup_MissingName(t *testing.T) {
_, err := CreateTargetGroup(&elbv2.CreateTargetGroupInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDeleteTargetGroup_NilInput(t *testing.T) {
_, err := DeleteTargetGroup(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestDeleteTargetGroup_MissingArn(t *testing.T) {
_, err := DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDescribeTargetGroups_NilInput(t *testing.T) {
_, err := DescribeTargetGroups(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestRegisterTargets_NilInput(t *testing.T) {
_, err := RegisterTargets(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestRegisterTargets_MissingArn(t *testing.T) {
_, err := RegisterTargets(&elbv2.RegisterTargetsInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestRegisterTargets_MissingTargets(t *testing.T) {
_, err := RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/abc"),
}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDeregisterTargets_NilInput(t *testing.T) {
_, err := DeregisterTargets(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestDeregisterTargets_MissingArn(t *testing.T) {
_, err := DeregisterTargets(&elbv2.DeregisterTargetsInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDeregisterTargets_MissingTargets(t *testing.T) {
_, err := DeregisterTargets(&elbv2.DeregisterTargetsInput{
TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/abc"),
}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDescribeTargetHealth_NilInput(t *testing.T) {
_, err := DescribeTargetHealth(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestDescribeTargetHealth_MissingArn(t *testing.T) {
_, err := DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestCreateListener_NilInput(t *testing.T) {
_, err := CreateListener(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestCreateListener_MissingLBArn(t *testing.T) {
_, err := CreateListener(&elbv2.CreateListenerInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestCreateListener_MissingActions(t *testing.T) {
_, err := CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/abc"),
}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDeleteListener_NilInput(t *testing.T) {
_, err := DeleteListener(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestDeleteListener_MissingArn(t *testing.T) {
_, err := DeleteListener(&elbv2.DeleteListenerInput{}, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
}
func TestDescribeListeners_NilInput(t *testing.T) {
_, err := DescribeListeners(nil, nil, "123456789012")
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
+45
View File
@@ -0,0 +1,45 @@
package gateway
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestELBv2ActionsMap_AllActionsRegistered(t *testing.T) {
expectedActions := []string{
"CreateLoadBalancer",
"DeleteLoadBalancer",
"DescribeLoadBalancers",
"CreateTargetGroup",
"DeleteTargetGroup",
"DescribeTargetGroups",
"RegisterTargets",
"DeregisterTargets",
"DescribeTargetHealth",
"CreateListener",
"DeleteListener",
"DescribeListeners",
}
for _, action := range expectedActions {
_, ok := elbv2Actions[action]
assert.True(t, ok, "action %q should be registered in elbv2Actions", action)
}
assert.Len(t, elbv2Actions, len(expectedActions), "elbv2Actions should have exactly %d actions", len(expectedActions))
}
func TestELBv2ActionsMap_UnknownActionNotRegistered(t *testing.T) {
unknownActions := []string{
"ModifyLoadBalancer",
"CreateRule",
"DescribeRules",
"RunInstances",
}
for _, action := range unknownActions {
_, ok := elbv2Actions[action]
assert.False(t, ok, "action %q should not be in elbv2Actions", action)
}
}
+6 -3
View File
@@ -47,9 +47,10 @@ type GatewayConfig struct {
}
var supportedServices = map[string]bool{
"ec2": true,
"iam": true,
"account": true,
"ec2": true,
"iam": true,
"account": true,
"elasticloadbalancing": true,
}
type ErrorResponse struct {
@@ -189,6 +190,8 @@ func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) {
err = gw.Account_Request(w, r)
case "iam":
err = gw.IAM_Request(w, r)
case "elasticloadbalancing":
err = gw.ELBv2_Request(w, r)
default:
err = errors.New(awserrors.ErrorUnsupportedOperation)
}
+1
View File
@@ -375,6 +375,7 @@ func TestSupportedServices(t *testing.T) {
assert.True(t, supportedServices["ec2"])
assert.True(t, supportedServices["iam"])
assert.True(t, supportedServices["account"])
assert.True(t, supportedServices["elasticloadbalancing"])
assert.False(t, supportedServices["s3"])
assert.False(t, supportedServices["dynamodb"])
assert.False(t, supportedServices[""])
+55 -22
View File
@@ -2,6 +2,7 @@ package handlers_ec2_instance
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
@@ -28,23 +29,30 @@ import (
)
const cloudInitUserDataTemplate = `#cloud-config
{{if .SSHKey}}
users:
- name: {{.Username}}
shell: /bin/bash
shell: /bin/sh
groups:
- sudo
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- {{.SSHKey}}
{{else}}
chpasswd:
expire: false
users:
- name: root
password: spinifex
type: text
ssh_pwauth: true
{{end}}
hostname: {{.Hostname}}
manage_etc_hosts: true
{{if .UserDataCloudConfig}}
# custom userdata cloud-config
{{.UserDataCloudConfig}}
{{end}}
{{if .UserDataScript}}
@@ -55,7 +63,7 @@ write_files:
{{.UserDataScript}}
runcmd:
- [ "/bin/bash", "/tmp/cloud-init-startup.sh" ]
- [ "/bin/sh", "/tmp/cloud-init-startup.sh" ]
{{end}}
`
@@ -596,30 +604,33 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
// Generate instance metadata
hostname := generateHostname(instance.ID)
// Retrieve SSH pubkey from S3
// Retrieve SSH pubkey from S3 (optional — system instances may not have a key)
keyName := ""
if input.KeyName != nil {
keyName = *input.KeyName
}
keyPath := fmt.Sprintf("keys/%s/%s", instance.AccountID, keyName)
result, err := s.objectStore.GetObject(&awss3.GetObjectInput{
Bucket: aws.String(s.config.Predastore.Bucket),
Key: aws.String(keyPath),
})
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
slog.Error("key pair not found", "keyName", keyName, "err", err)
return errors.New(awserrors.ErrorInvalidKeyPairNotFound)
var sshKey []byte
if keyName != "" {
keyPath := fmt.Sprintf("keys/%s/%s", instance.AccountID, keyName)
result, err := s.objectStore.GetObject(&awss3.GetObjectInput{
Bucket: aws.String(s.config.Predastore.Bucket),
Key: aws.String(keyPath),
})
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
slog.Error("key pair not found", "keyName", keyName, "err", err)
return errors.New(awserrors.ErrorInvalidKeyPairNotFound)
}
slog.Error("failed to read SSH key", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
slog.Error("failed to read SSH key", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
sshKey, err := io.ReadAll(result.Body)
if err != nil {
slog.Error("failed to read SSH key body", "err", err)
return errors.New(awserrors.ErrorServerInternal)
sshKey, err = io.ReadAll(result.Body)
if err != nil {
slog.Error("failed to read SSH key body", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
}
userData := CloudInitData{
@@ -628,6 +639,28 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
Hostname: hostname,
}
// Decode and classify user-data from RunInstances (base64-encoded).
if input.UserData != nil && *input.UserData != "" {
decoded, decErr := base64.StdEncoding.DecodeString(*input.UserData)
if decErr != nil {
slog.Warn("Failed to decode user-data, ignoring", "err", decErr)
} else {
raw := string(decoded)
if strings.HasPrefix(raw, "#cloud-config") {
// Strip the #cloud-config header — the template already has it
stripped := strings.TrimPrefix(raw, "#cloud-config")
userData.UserDataCloudConfig = strings.TrimSpace(stripped)
} else {
// Script — indent each line by 4 spaces for YAML write_files block
var indented strings.Builder
for _, line := range strings.Split(raw, "\n") {
indented.WriteString(" " + line + "\n")
}
userData.UserDataScript = indented.String()
}
}
}
var buf bytes.Buffer
t := template.Must(template.New("cloud-init").Parse(cloudInitUserDataTemplate))
@@ -201,7 +201,6 @@ func TestCloudInitTemplateRendering(t *testing.T) {
contains: []string{
"packages:",
"nginx",
"custom userdata cloud-config",
},
},
{
+11 -1
View File
@@ -227,7 +227,7 @@ func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(input *ec2.ModifyNetwor
// DescribeNetworkInterfaces lists ENIs with optional filters
func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInterfacesInput, accountID string) (*ec2.DescribeNetworkInterfacesOutput, error) {
var enis []*ec2.NetworkInterface
enis := make([]*ec2.NetworkInterface, 0)
eniIDs := make(map[string]bool)
for _, id := range input.NetworkInterfaceIds {
@@ -239,6 +239,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInt
// Extract filters
subnetFilter := ""
vpcFilter := ""
descriptionFilter := ""
attachmentInstanceFilter := ""
for _, f := range input.Filters {
if f.Name == nil || len(f.Values) == 0 || f.Values[0] == nil {
@@ -249,6 +250,8 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInt
subnetFilter = *f.Values[0]
case "vpc-id":
vpcFilter = *f.Values[0]
case "description":
descriptionFilter = *f.Values[0]
case "attachment.instance-id":
attachmentInstanceFilter = *f.Values[0]
}
@@ -290,6 +293,9 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInt
if vpcFilter != "" && record.VpcId != vpcFilter {
continue
}
if descriptionFilter != "" && record.Description != descriptionFilter {
continue
}
if attachmentInstanceFilter != "" && record.InstanceId != attachmentInstanceFilter {
continue
}
@@ -416,6 +422,9 @@ func (s *VPCServiceImpl) UpdateENIPublicIP(accountID, eniId, publicIP, poolName
// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec2.NetworkInterface {
// ENIs with spinifex:managed-by tag are system-managed (e.g. by ELBv2)
requesterManaged := record.Tags["spinifex:managed-by"] != ""
eni := &ec2.NetworkInterface{
NetworkInterfaceId: aws.String(record.NetworkInterfaceId),
SubnetId: aws.String(record.SubnetId),
@@ -426,6 +435,7 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec
Description: aws.String(record.Description),
Status: aws.String(record.Status),
OwnerId: aws.String(accountID),
RequesterManaged: aws.Bool(requesterManaged),
InterfaceType: aws.String("interface"),
SourceDestCheck: aws.Bool(true),
PrivateIpAddresses: []*ec2.NetworkInterfacePrivateIpAddress{
+29
View File
@@ -332,6 +332,35 @@ func TestDescribeNetworkInterfaces_FilterByAttachmentInstanceId(t *testing.T) {
assert.Equal(t, eni1, *out.NetworkInterfaces[0].NetworkInterfaceId)
}
func TestDescribeNetworkInterfaces_FilterByDescription(t *testing.T) {
svc := setupTestVPCService(t)
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
// Create two ENIs with different descriptions
out1, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetId),
Description: aws.String("ELB app/my-alb/lb-123"),
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetId),
Description: aws.String("regular ENI"),
}, testAccountID)
require.NoError(t, err)
// Filter by description should return only the ALB ENI
desc, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
Filters: []*ec2.Filter{
{Name: aws.String("description"), Values: []*string{aws.String("ELB app/my-alb/lb-123")}},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
assert.Equal(t, *out1.NetworkInterface.NetworkInterfaceId, *desc.NetworkInterfaces[0].NetworkInterfaceId)
}
func TestCreateNetworkInterface_IPExhaustion(t *testing.T) {
svc := setupTestVPCService(t)
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+385
View File
@@ -0,0 +1,385 @@
package handlers_elbv2
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"text/template"
)
const haproxyConfigTemplate = `# Auto-generated by spinifex ELBv2 — do not edit
global
log stdout format short daemon
stats socket {{.SocketPath}} mode 660 level admin
defaults
mode http
log global
option httplog
timeout connect 5s
timeout client 60s
timeout server 60s
{{range .Frontends}}
frontend {{.Name}}
bind *:{{.Port}}
default_backend {{.DefaultBackend}}
{{end}}
{{range .Backends}}
backend {{.Name}}
balance roundrobin{{if .HealthCheckPath}}
option httpchk GET {{.HealthCheckPath}}
http-check expect status {{.HealthCheckMatcher}}{{end}}
{{- range .Servers}}
server {{.Name}} {{.Addr}}:{{.Port}} check inter {{.CheckInterval}}s fall {{.Fall}} rise {{.Rise}}
{{- end}}
{{end}}
`
// HAProxyConfig holds the full configuration for rendering the HAProxy template.
type HAProxyConfig struct {
SocketPath string
Frontends []HAProxyFrontend
Backends []HAProxyBackend
}
// HAProxyFrontend represents a listener frontend.
type HAProxyFrontend struct {
Name string
BindAddr string
Port int64
DefaultBackend string
}
// HAProxyBackend represents a target group backend.
type HAProxyBackend struct {
Name string
HealthCheckPath string
HealthCheckMatcher string
Servers []HAProxyServer
}
// HAProxyServer represents a single target server.
type HAProxyServer struct {
Name string
Addr string
Port int64
CheckInterval int64
Fall int64
Rise int64
}
// GenerateHAProxyConfig builds an HAProxy config string from the LB, its listeners,
// and associated target groups.
func GenerateHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, bindAddr string) (string, error) {
socketDir := filepath.Join(os.TempDir(), "spinifex-haproxy")
socketPath := filepath.Join(socketDir, fmt.Sprintf("alb-%s.sock", lb.LoadBalancerID))
cfg := HAProxyConfig{
SocketPath: socketPath,
}
for _, l := range listeners {
if len(l.DefaultActions) == 0 {
continue
}
tgArn := l.DefaultActions[0].TargetGroupArn
backendName := sanitizeName("bk", tgArn)
cfg.Frontends = append(cfg.Frontends, HAProxyFrontend{
Name: sanitizeName("ft", l.ListenerArn),
BindAddr: bindAddr,
Port: l.Port,
DefaultBackend: backendName,
})
tg, ok := tgByArn[tgArn]
if !ok {
continue
}
// Check if backend already added (multiple listeners can share a TG)
alreadyAdded := false
for _, b := range cfg.Backends {
if b.Name == backendName {
alreadyAdded = true
break
}
}
if alreadyAdded {
continue
}
backend := HAProxyBackend{
Name: backendName,
HealthCheckPath: tg.HealthCheck.Path,
HealthCheckMatcher: tg.HealthCheck.Matcher,
}
for _, target := range tg.Targets {
if target.PrivateIP == "" || target.HealthState == TargetHealthDraining {
continue
}
backend.Servers = append(backend.Servers, HAProxyServer{
Name: sanitizeName("srv", target.Id),
Addr: target.PrivateIP,
Port: target.Port,
CheckInterval: tg.HealthCheck.IntervalSeconds,
Fall: tg.HealthCheck.UnhealthyThreshold,
Rise: tg.HealthCheck.HealthyThreshold,
})
}
cfg.Backends = append(cfg.Backends, backend)
}
tmpl, err := template.New("haproxy").Parse(haproxyConfigTemplate)
if err != nil {
return "", fmt.Errorf("parse haproxy template: %w", err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, cfg); err != nil {
return "", fmt.Errorf("execute haproxy template: %w", err)
}
return buf.String(), nil
}
// sanitizeName creates a safe identifier from an ARN or ID for use in HAProxy config.
func sanitizeName(prefix, id string) string {
// Use last segment of ARN or ID, replace non-alphanumeric with underscore
parts := strings.Split(id, "/")
name := parts[len(parts)-1]
var sb strings.Builder
for _, c := range name {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
sb.WriteRune(c)
} else {
sb.WriteRune('_')
}
}
return prefix + "_" + sb.String()
}
// HAProxyManager manages HAProxy processes for ALBs on this daemon node.
type HAProxyManager struct {
configDir string // Directory for HAProxy config files
binPath string // Path to haproxy binary
mu sync.Mutex
pids map[string]int // lbID → HAProxy PID
}
// NewHAProxyManager creates a new HAProxy manager.
func NewHAProxyManager(configDir string) *HAProxyManager {
binPath := "/usr/sbin/haproxy"
if p, err := exec.LookPath("haproxy"); err == nil {
binPath = p
}
return &HAProxyManager{
configDir: configDir,
binPath: binPath,
pids: make(map[string]int),
}
}
// Available returns true if the haproxy binary is found on the system.
func (m *HAProxyManager) Available() bool {
_, err := exec.LookPath(m.binPath)
if err != nil {
// binPath might be absolute — check directly
_, err = os.Stat(m.binPath)
}
return err == nil
}
// pidFilePath returns the pidfile path for a given LB.
func (m *HAProxyManager) pidFilePath(lbID string) string {
return filepath.Join(m.configDir, fmt.Sprintf("alb-%s.pid", lbID))
}
// configFilePath returns the config path for a given LB.
func (m *HAProxyManager) configFilePath(lbID string) string {
return filepath.Join(m.configDir, fmt.Sprintf("alb-%s.cfg", lbID))
}
// IsRunning returns true if an HAProxy process is tracked and alive for this LB.
func (m *HAProxyManager) IsRunning(lbID string) bool {
m.mu.Lock()
pid, ok := m.pids[lbID]
m.mu.Unlock()
if !ok || pid <= 0 {
return false
}
// Check if process is alive
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
// Start launches a new HAProxy process for the given LB.
func (m *HAProxyManager) Start(lbID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := os.MkdirAll(m.configDir, 0o750); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
// Ensure the stats socket directory exists
socketDir := filepath.Join(os.TempDir(), "spinifex-haproxy")
if err := os.MkdirAll(socketDir, 0o750); err != nil {
return fmt.Errorf("create socket dir: %w", err)
}
configPath := m.configFilePath(lbID)
pidFile := m.pidFilePath(lbID)
cmd := exec.Command(m.binPath, "-f", configPath, "-p", pidFile, "-D")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("haproxy start failed: %s: %w", strings.TrimSpace(string(output)), err)
}
// Read PID from pidfile
pid, err := m.readPidFile(pidFile)
if err != nil {
slog.Warn("HAProxy started but could not read pidfile", "lbId", lbID, "err", err)
return nil
}
m.pids[lbID] = pid
slog.Info("HAProxy started", "lbId", lbID, "pid", pid)
return nil
}
// Reload performs a graceful reload of the HAProxy process for the given LB.
// The old workers finish in-flight connections while new workers start.
func (m *HAProxyManager) Reload(lbID string) error {
m.mu.Lock()
defer m.mu.Unlock()
oldPid, ok := m.pids[lbID]
if !ok || oldPid <= 0 {
// Not running — do a fresh start instead (unlock first)
m.mu.Unlock()
err := m.Start(lbID)
m.mu.Lock()
return err
}
configPath := m.configFilePath(lbID)
pidFile := m.pidFilePath(lbID)
cmd := exec.Command(m.binPath, "-f", configPath, "-p", pidFile, "-D", "-sf", strconv.Itoa(oldPid))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("haproxy reload failed: %s: %w", strings.TrimSpace(string(output)), err)
}
// Read new PID
newPid, err := m.readPidFile(pidFile)
if err != nil {
slog.Warn("HAProxy reloaded but could not read new pidfile", "lbId", lbID, "err", err)
return nil
}
m.pids[lbID] = newPid
slog.Info("HAProxy reloaded", "lbId", lbID, "oldPid", oldPid, "newPid", newPid)
return nil
}
// Stop terminates the HAProxy process for the given LB and cleans up files.
func (m *HAProxyManager) Stop(lbID string) error {
m.mu.Lock()
defer m.mu.Unlock()
pid, ok := m.pids[lbID]
if ok && pid > 0 {
proc, err := os.FindProcess(pid)
if err == nil {
if err := proc.Signal(syscall.SIGTERM); err != nil {
slog.Debug("HAProxy SIGTERM failed (may already be stopped)", "lbId", lbID, "pid", pid, "err", err)
}
}
}
delete(m.pids, lbID)
// Clean up pidfile
pidFile := m.pidFilePath(lbID)
os.Remove(pidFile)
slog.Info("HAProxy stopped", "lbId", lbID, "pid", pid)
return nil
}
// StopAll terminates all tracked HAProxy processes. Called on daemon shutdown.
func (m *HAProxyManager) StopAll() {
m.mu.Lock()
lbIDs := make([]string, 0, len(m.pids))
for id := range m.pids {
lbIDs = append(lbIDs, id)
}
m.mu.Unlock()
for _, id := range lbIDs {
if err := m.Stop(id); err != nil {
slog.Warn("Failed to stop HAProxy on shutdown", "lbId", id, "err", err)
}
}
}
// readPidFile reads and parses a PID from a pidfile.
func (m *HAProxyManager) readPidFile(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0, fmt.Errorf("parse pid %q: %w", string(data), err)
}
return pid, nil
}
// WriteConfig writes an HAProxy config file for the given load balancer.
func (m *HAProxyManager) WriteConfig(lbID, configContent string) (string, error) {
if err := os.MkdirAll(m.configDir, 0o750); err != nil {
return "", fmt.Errorf("create config dir: %w", err)
}
path := filepath.Join(m.configDir, fmt.Sprintf("alb-%s.cfg", lbID))
if err := os.WriteFile(path, []byte(configContent), 0o640); err != nil {
return "", fmt.Errorf("write haproxy config: %w", err)
}
slog.Info("Wrote HAProxy config", "path", path, "lbId", lbID)
return path, nil
}
// RemoveConfig removes the HAProxy config file for the given load balancer.
func (m *HAProxyManager) RemoveConfig(lbID string) error {
path := filepath.Join(m.configDir, fmt.Sprintf("alb-%s.cfg", lbID))
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove haproxy config: %w", err)
}
return nil
}
// ValidateConfig checks if an HAProxy config file is syntactically valid.
func (m *HAProxyManager) ValidateConfig(configPath string) error {
cmd := exec.Command(m.binPath, "-c", "-f", configPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("haproxy config validation failed: %s: %w", string(output), err)
}
return nil
}
+348
View File
@@ -0,0 +1,348 @@
package handlers_elbv2
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateHAProxyConfig_SingleListenerAndBackend(t *testing.T) {
lb := &LoadBalancerRecord{
LoadBalancerID: "lb-abc123",
Name: "my-alb",
}
listeners := []*ListenerRecord{
{
ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/lb-abc123/lst-111",
LoadBalancerArn: lb.LoadBalancerArn,
Protocol: ProtocolHTTP,
Port: 80,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/my-tg/tg-222"},
},
},
}
tgByArn := map[string]*TargetGroupRecord{
"arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/my-tg/tg-222": {
TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/my-tg/tg-222",
Name: "my-tg",
HealthCheck: HealthCheckConfig{
Path: "/health",
Matcher: "200",
IntervalSeconds: 30,
UnhealthyThreshold: 2,
HealthyThreshold: 5,
},
Targets: []Target{
{Id: "i-aaa111", Port: 8080, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
{Id: "i-bbb222", Port: 8080, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "10.0.1.5")
require.NoError(t, err)
// Verify global section
assert.Contains(t, config, "stats socket")
assert.Contains(t, config, "alb-lb-abc123.sock")
// Verify frontend
assert.Contains(t, config, "bind *:80")
assert.Contains(t, config, "default_backend")
// Verify backend
assert.Contains(t, config, "option httpchk GET /health")
assert.Contains(t, config, "http-check expect status 200")
assert.Contains(t, config, "10.0.1.10:8080")
assert.Contains(t, config, "10.0.1.11:8080")
assert.Contains(t, config, "check inter 30s fall 2 rise 5")
}
func TestGenerateHAProxyConfig_MultipleListeners(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-multi"}
tgArn1 := "arn:tg1"
tgArn2 := "arn:tg2"
listeners := []*ListenerRecord{
{
ListenerArn: "arn:lst1",
Port: 80,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: tgArn1},
},
},
{
ListenerArn: "arn:lst2",
Port: 8080,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: tgArn2},
},
},
}
tgByArn := map[string]*TargetGroupRecord{
tgArn1: {
HealthCheck: DefaultHealthCheck(),
Targets: []Target{{Id: "i-1", Port: 80, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy}},
},
tgArn2: {
HealthCheck: DefaultHealthCheck(),
Targets: []Target{{Id: "i-2", Port: 9090, PrivateIP: "10.0.0.2", HealthState: TargetHealthHealthy}},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "0.0.0.0")
require.NoError(t, err)
assert.Contains(t, config, "bind *:80")
assert.Contains(t, config, "bind *:8080")
assert.Contains(t, config, "10.0.0.1:80")
assert.Contains(t, config, "10.0.0.2:9090")
}
func TestGenerateHAProxyConfig_SkipsDrainingTargets(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-drain"}
tgArn := "arn:tg-drain"
listeners := []*ListenerRecord{
{
ListenerArn: "arn:lst-drain",
Port: 80,
DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}},
},
}
tgByArn := map[string]*TargetGroupRecord{
tgArn: {
HealthCheck: DefaultHealthCheck(),
Targets: []Target{
{Id: "i-active", Port: 80, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
{Id: "i-draining", Port: 80, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
{Id: "i-no-ip", Port: 80, PrivateIP: "", HealthState: TargetHealthInitial},
},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "0.0.0.0")
require.NoError(t, err)
assert.Contains(t, config, "10.0.0.1:80")
assert.NotContains(t, config, "10.0.0.2") // draining
assert.NotContains(t, config, "i-no-ip") // no IP
}
func TestGenerateHAProxyConfig_SharedTargetGroup(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-shared"}
tgArn := "arn:shared-tg"
// Two listeners pointing to same TG
listeners := []*ListenerRecord{
{ListenerArn: "arn:lst-a", Port: 80, DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}}},
{ListenerArn: "arn:lst-b", Port: 443, DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}}},
}
tgByArn := map[string]*TargetGroupRecord{
tgArn: {
HealthCheck: DefaultHealthCheck(),
Targets: []Target{{Id: "i-1", Port: 80, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy}},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "0.0.0.0")
require.NoError(t, err)
// Backend should only appear once
assert.Equal(t, 1, strings.Count(config, "balance roundrobin"))
}
func TestGenerateHAProxyConfig_NoListeners(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-empty"}
config, err := GenerateHAProxyConfig(lb, nil, nil, "0.0.0.0")
require.NoError(t, err)
// Should still produce a valid config (global + defaults, no frontends/backends)
assert.Contains(t, config, "global")
assert.Contains(t, config, "defaults")
assert.NotContains(t, config, "frontend")
assert.NotContains(t, config, "backend")
}
func TestSanitizeName(t *testing.T) {
tests := []struct {
prefix string
input string
want string
}{
{"bk", "tg-abc123", "bk_tg-abc123"},
{"ft", "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/lb-123/lst-456", "bk" + ""},
{"srv", "i-abcdef123", "srv_i-abcdef123"},
}
// Just verify no panics and reasonable output
for _, tc := range tests {
result := sanitizeName(tc.prefix, tc.input)
assert.True(t, strings.HasPrefix(result, tc.prefix+"_"))
assert.NotContains(t, result, ":")
assert.NotContains(t, result, "/")
}
}
func TestHAProxyManager_Available_NotFound(t *testing.T) {
mgr := NewHAProxyManager(t.TempDir())
mgr.binPath = "/nonexistent/haproxy-fake-bin"
assert.False(t, mgr.Available())
}
func TestHAProxyManager_IsRunning_UnknownLB(t *testing.T) {
mgr := NewHAProxyManager(t.TempDir())
assert.False(t, mgr.IsRunning("lb-unknown"))
}
func TestHAProxyManager_Stop_NotRunning(t *testing.T) {
mgr := NewHAProxyManager(t.TempDir())
err := mgr.Stop("lb-not-running")
require.NoError(t, err)
}
func TestHAProxyManager_StopAll_Empty(t *testing.T) {
mgr := NewHAProxyManager(t.TempDir())
mgr.StopAll() // should not panic
}
func TestHAProxyManager_PidFilePath(t *testing.T) {
mgr := NewHAProxyManager("/tmp/haproxy-test")
assert.Equal(t, "/tmp/haproxy-test/alb-lb-123.pid", mgr.pidFilePath("lb-123"))
assert.Equal(t, "/tmp/haproxy-test/alb-lb-123.cfg", mgr.configFilePath("lb-123"))
}
func TestHAProxyManager_Start_MissingBinary(t *testing.T) {
dir := t.TempDir()
mgr := NewHAProxyManager(dir)
mgr.binPath = "/nonexistent/haproxy"
// Write a config so the path exists
_, err := mgr.WriteConfig("lb-test", "global\n")
require.NoError(t, err)
err = mgr.Start("lb-test")
require.Error(t, err)
assert.Contains(t, err.Error(), "haproxy start failed")
}
func TestHAProxyManager_Reload_NotRunning_FallsBackToStart(t *testing.T) {
dir := t.TempDir()
mgr := NewHAProxyManager(dir)
mgr.binPath = "/nonexistent/haproxy"
// Write a config so the path exists
_, err := mgr.WriteConfig("lb-test", "global\n")
require.NoError(t, err)
// Reload when not running should attempt Start (which will fail due to bad binary)
err = mgr.Reload("lb-test")
require.Error(t, err)
assert.Contains(t, err.Error(), "haproxy start failed")
}
func TestHAProxyManager_LifecycleWithFakeHAProxy(t *testing.T) {
// Create a fake haproxy script that writes its PID to the pidfile
dir := t.TempDir()
fakeHAProxy := filepath.Join(dir, "fake-haproxy")
script := `#!/bin/sh
# Parse -p flag for pidfile
PIDFILE=""
SF_PID=""
while [ $# -gt 0 ]; do
case "$1" in
-p) PIDFILE="$2"; shift 2;;
-D) shift;;
-f) shift 2;;
-c) exit 0;;
-sf) SF_PID="$2"; shift 2;;
*) shift;;
esac
done
# Kill old process if -sf was passed
if [ -n "$SF_PID" ]; then
kill "$SF_PID" 2>/dev/null || true
fi
# Start a background sleep with closed fds so parent can exit
sleep 60 </dev/null >/dev/null 2>&1 &
BGPID=$!
if [ -n "$PIDFILE" ]; then
echo "$BGPID" > "$PIDFILE"
fi
`
require.NoError(t, os.WriteFile(fakeHAProxy, []byte(script), 0o755))
configDir := filepath.Join(dir, "configs")
mgr := NewHAProxyManager(configDir)
mgr.binPath = fakeHAProxy
lbID := "lb-lifecycle-test"
// Write config
content := "global\n log stdout\n"
_, err := mgr.WriteConfig(lbID, content)
require.NoError(t, err)
// Not running yet
assert.False(t, mgr.IsRunning(lbID))
// Start
err = mgr.Start(lbID)
require.NoError(t, err)
assert.True(t, mgr.IsRunning(lbID))
// Reload
err = mgr.Reload(lbID)
require.NoError(t, err)
assert.True(t, mgr.IsRunning(lbID))
// Stop
err = mgr.Stop(lbID)
require.NoError(t, err)
assert.False(t, mgr.IsRunning(lbID))
// Pidfile should be cleaned up
_, err = os.Stat(mgr.pidFilePath(lbID))
assert.True(t, os.IsNotExist(err))
}
func TestHAProxyManager_WriteAndRemoveConfig(t *testing.T) {
dir := t.TempDir()
mgr := &HAProxyManager{configDir: dir, binPath: "/usr/sbin/haproxy"}
content := "global\n log stdout\n"
path, err := mgr.WriteConfig("lb-test", content)
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "alb-lb-test.cfg"), path)
// Verify file contents
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, content, string(data))
// Remove
err = mgr.RemoveConfig("lb-test")
require.NoError(t, err)
_, err = os.Stat(path)
assert.True(t, os.IsNotExist(err))
}
func TestHAProxyManager_RemoveConfig_Idempotent(t *testing.T) {
dir := t.TempDir()
mgr := &HAProxyManager{configDir: dir, binPath: "/usr/sbin/haproxy"}
err := mgr.RemoveConfig("nonexistent")
require.NoError(t, err)
}
+49
View File
@@ -0,0 +1,49 @@
package handlers_elbv2
// SystemInstanceLauncher is the subset of daemon functionality needed by
// ELBv2 to launch and terminate system-managed VMs (e.g. ALB VMs).
// Defined here to avoid a circular import between handlers/elbv2 and daemon.
//
// System instances are owned by the system account and hidden from
// customer DescribeInstances calls.
type SystemInstanceLauncher interface {
// LaunchSystemInstance creates and starts a system-managed VM.
// The VM is assigned to the system account and not visible to customers.
// The caller provides the instance type, AMI, subnet, user data, and
// optionally a pre-created ENI to attach.
//
// Returns the instance ID and private IP once the VM is running.
LaunchSystemInstance(input *SystemInstanceInput) (*SystemInstanceOutput, error)
// TerminateSystemInstance stops and cleans up a system-managed VM.
TerminateSystemInstance(instanceID string) error
}
// SystemInstanceInput describes the VM to launch.
type SystemInstanceInput struct {
InstanceType string // e.g. "t3.nano"
ImageID string // AMI ID
SubnetID string // VPC subnet for networking
UserData string // Cloud-init user data (plain text, will be base64-encoded)
// ENI fields — if ENIID is set, the VM uses this pre-created ENI instead
// of auto-creating one. Used for ALB VMs that reuse the ALB's ENI.
ENIID string
ENIMac string
ENIIP string
// HostfwdPorts specifies additional guest ports to forward from the host
// via the QEMU user-mode dev NIC (dev_networking mode only).
// Each entry is a guest port (e.g. 80, 443). The host port is auto-assigned.
HostfwdPorts []int
}
// SystemInstanceOutput contains the result of a successful launch.
type SystemInstanceOutput struct {
InstanceID string // e.g. "i-xxxxx"
PrivateIP string // VPC private IP
// HostfwdMap maps guest port → host port for any forwarded ports.
// Only populated when dev_networking is enabled and HostfwdPorts were requested.
HostfwdMap map[int]int
}
+165
View File
@@ -0,0 +1,165 @@
package handlers_elbv2
import (
"fmt"
"testing"
)
// mockSystemInstanceLauncher implements SystemInstanceLauncher for tests.
type mockSystemInstanceLauncher struct {
launchCalls []*SystemInstanceInput
terminateCalls []string
launchResult *SystemInstanceOutput
launchErr error
terminateErr error
}
func (m *mockSystemInstanceLauncher) LaunchSystemInstance(input *SystemInstanceInput) (*SystemInstanceOutput, error) {
m.launchCalls = append(m.launchCalls, input)
return m.launchResult, m.launchErr
}
func (m *mockSystemInstanceLauncher) TerminateSystemInstance(instanceID string) error {
m.terminateCalls = append(m.terminateCalls, instanceID)
return m.terminateErr
}
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.
var _ SystemInstanceLauncher = (*mockSystemInstanceLauncher)(nil)
+22
View File
@@ -0,0 +1,22 @@
package handlers_elbv2
import "github.com/aws/aws-sdk-go/service/elbv2"
// ELBv2Service defines the interface for Application Load Balancer operations.
type ELBv2Service interface {
CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, accountID string) (*elbv2.CreateLoadBalancerOutput, error)
DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, accountID string) (*elbv2.DeleteLoadBalancerOutput, error)
DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error)
CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error)
DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, accountID string) (*elbv2.DeleteTargetGroupOutput, error)
DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, accountID string) (*elbv2.DescribeTargetGroupsOutput, error)
RegisterTargets(input *elbv2.RegisterTargetsInput, accountID string) (*elbv2.RegisterTargetsOutput, error)
DeregisterTargets(input *elbv2.DeregisterTargetsInput, accountID string) (*elbv2.DeregisterTargetsOutput, error)
DescribeTargetHealth(input *elbv2.DescribeTargetHealthInput, accountID string) (*elbv2.DescribeTargetHealthOutput, error)
CreateListener(input *elbv2.CreateListenerInput, accountID string) (*elbv2.CreateListenerOutput, error)
DeleteListener(input *elbv2.DeleteListenerInput, accountID string) (*elbv2.DeleteListenerOutput, error)
DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
package handlers_elbv2
import (
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupTestServiceWithInstance creates an ELBv2 + VPC service with a VPC, subnet,
// and a simulated instance ENI (attached to an instance ID).
func setupTestServiceWithInstance(t *testing.T, instanceID, instanceIP string) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl, string) {
t.Helper()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(nil, nc)
require.NoError(t, err)
elbv2Svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
require.NoError(t, err)
elbv2Svc.SetVPCService(vpcSvc)
// Create VPC + subnet
vpcOut, err := vpcSvc.CreateVpc(&ec2.CreateVpcInput{
CidrBlock: aws.String("10.0.0.0/16"),
}, testAccountID)
require.NoError(t, err)
subnetOut, err := vpcSvc.CreateSubnet(&ec2.CreateSubnetInput{
VpcId: vpcOut.Vpc.VpcId,
CidrBlock: aws.String("10.0.1.0/24"),
AvailabilityZone: aws.String("us-east-1a"),
}, testAccountID)
require.NoError(t, err)
// Create an ENI for the "instance" with specific IP
eniOut, err := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: subnetOut.Subnet.SubnetId,
PrivateIpAddress: aws.String(instanceIP),
Description: aws.String("Primary ENI for " + instanceID),
}, testAccountID)
require.NoError(t, err)
// Attach ENI to instance (simulates RunInstances)
_, err = vpcSvc.AttachENI(testAccountID, *eniOut.NetworkInterface.NetworkInterfaceId, instanceID, 0)
require.NoError(t, err)
return elbv2Svc, vpcSvc, *subnetOut.Subnet.SubnetId
}
func TestRegisterTargets_ResolvesPrivateIP(t *testing.T) {
svc, _, _ := setupTestServiceWithInstance(t, "i-web001", "10.0.1.50")
// Create target group
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("ip-resolve-tg"),
Port: aws.Int64(80),
}, testAccountID)
require.NoError(t, err)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
// Register the instance
_, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-web001")}},
}, testAccountID)
require.NoError(t, err)
// Verify private IP was resolved
health, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
TargetGroupArn: tgArn,
}, testAccountID)
require.NoError(t, err)
require.Len(t, health.TargetHealthDescriptions, 1)
// The target should have been registered with the resolved IP in the store
tg, err := svc.store.GetTargetGroupByArn(*tgArn)
require.NoError(t, err)
require.Len(t, tg.Targets, 1)
assert.Equal(t, "10.0.1.50", tg.Targets[0].PrivateIP)
}
func TestRegisterTargets_UnresolvableIP(t *testing.T) {
svc, _, _ := setupTestServiceWithInstance(t, "i-web001", "10.0.1.50")
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("unresolvable-tg"),
Port: aws.Int64(80),
}, testAccountID)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
// Register an instance that doesn't exist — should still succeed with empty IP
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-nonexistent")}},
}, testAccountID)
require.NoError(t, err)
tg, _ := svc.store.GetTargetGroupByArn(*tgArn)
require.Len(t, tg.Targets, 1)
assert.Equal(t, "", tg.Targets[0].PrivateIP)
}
func TestRegisterTargets_WithoutVPCService(t *testing.T) {
// When VPC service is nil, IP resolution is skipped gracefully
svc := setupTestService(t)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("no-vpc-tg"),
Port: aws.Int64(80),
}, testAccountID)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-any")}},
}, testAccountID)
require.NoError(t, err)
tg, _ := svc.store.GetTargetGroupByArn(*tgArn)
assert.Equal(t, "", tg.Targets[0].PrivateIP)
}
func TestRegisterTargets_MultipleInstances(t *testing.T) {
svc, vpcSvc, _ := setupTestServiceWithInstance(t, "i-web001", "10.0.1.50")
// Create a second instance ENI
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
subnetID := subnets.Subnets[0].SubnetId
eni2, _ := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: subnetID,
PrivateIpAddress: aws.String("10.0.1.51"),
}, testAccountID)
vpcSvc.AttachENI(testAccountID, *eni2.NetworkInterface.NetworkInterfaceId, "i-web002", 0)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("multi-inst-tg"),
Port: aws.Int64(80),
}, testAccountID)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{
{Id: aws.String("i-web001")},
{Id: aws.String("i-web002")},
},
}, testAccountID)
require.NoError(t, err)
tg, _ := svc.store.GetTargetGroupByArn(*tgArn)
require.Len(t, tg.Targets, 2)
assert.Equal(t, "10.0.1.50", tg.Targets[0].PrivateIP)
assert.Equal(t, "10.0.1.51", tg.Targets[1].PrivateIP)
}
@@ -0,0 +1,643 @@
package handlers_elbv2
import (
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestService(t *testing.T) *ELBv2ServiceImpl {
t.Helper()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
require.NoError(t, err)
return svc
}
// --- Load Balancer tests ---
func TestCreateLoadBalancer(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("my-alb"),
Subnets: []*string{aws.String("subnet-aaa")},
SecurityGroups: []*string{aws.String("sg-111")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, out.LoadBalancers, 1)
lb := out.LoadBalancers[0]
assert.Equal(t, "my-alb", *lb.LoadBalancerName)
assert.Equal(t, "internet-facing", *lb.Scheme)
assert.Equal(t, "application", *lb.Type)
assert.Equal(t, "active", *lb.State.Code)
assert.Contains(t, *lb.DNSName, "my-alb")
assert.Contains(t, *lb.LoadBalancerArn, "loadbalancer/app/my-alb")
}
func TestCreateLoadBalancer_InternalScheme(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("internal-alb"),
Scheme: aws.String("internal"),
}, testAccountID)
require.NoError(t, err)
assert.Equal(t, "internal", *out.LoadBalancers[0].Scheme)
}
func TestCreateLoadBalancer_InvalidScheme(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("bad-scheme"),
Scheme: aws.String("banana"),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "InvalidScheme")
}
func TestCreateLoadBalancer_DuplicateName(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("dup-alb"),
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("dup-alb"),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "DuplicateLoadBalancerName")
}
func TestCreateLoadBalancer_MissingName(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{}, testAccountID)
assert.Error(t, err)
}
func TestCreateLoadBalancer_WithTags(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("tagged-alb"),
Tags: []*elbv2.Tag{
{Key: aws.String("Env"), Value: aws.String("test")},
},
}, testAccountID)
require.NoError(t, err)
// Tags are stored internally, verify via describe
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
Names: []*string{aws.String("tagged-alb")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.LoadBalancers, 1)
assert.Equal(t, *out.LoadBalancers[0].LoadBalancerArn, *desc.LoadBalancers[0].LoadBalancerArn)
}
func TestDeleteLoadBalancer(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("delete-me"),
}, testAccountID)
require.NoError(t, err)
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
LoadBalancerArn: out.LoadBalancers[0].LoadBalancerArn,
}, testAccountID)
require.NoError(t, err)
// Verify it's gone
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, testAccountID)
require.NoError(t, err)
assert.Empty(t, desc.LoadBalancers)
}
func TestDeleteLoadBalancer_NotFound(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/nope/xyz"),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "LoadBalancerNotFound")
}
func TestDeleteLoadBalancer_CleansUpListeners(t *testing.T) {
svc := setupTestService(t)
// Create LB, TG, and listener
lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-cleanup")}, testAccountID)
require.NoError(t, err)
lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-cleanup")}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbArn,
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
}, testAccountID)
require.NoError(t, err)
// Delete LB should clean up listener
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lbArn}, testAccountID)
require.NoError(t, err)
// Listener should be gone
lstDesc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{LoadBalancerArn: lbArn}, testAccountID)
require.NoError(t, err)
assert.Empty(t, lstDesc.Listeners)
}
func TestDescribeLoadBalancers_FilterByName(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("alb-one")}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("alb-two")}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
Names: []*string{aws.String("alb-one")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.LoadBalancers, 1)
assert.Equal(t, "alb-one", *desc.LoadBalancers[0].LoadBalancerName)
}
func TestDescribeLoadBalancers_AccountIsolation(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("acct-alb")}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, "999999999999")
require.NoError(t, err)
assert.Empty(t, desc.LoadBalancers)
}
// --- Target Group tests ---
func TestCreateTargetGroup(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("my-tg"),
Protocol: aws.String("HTTP"),
Port: aws.Int64(8080),
VpcId: aws.String("vpc-test"),
}, testAccountID)
require.NoError(t, err)
require.Len(t, out.TargetGroups, 1)
tg := out.TargetGroups[0]
assert.Equal(t, "my-tg", *tg.TargetGroupName)
assert.Equal(t, "HTTP", *tg.Protocol)
assert.Equal(t, int64(8080), *tg.Port)
assert.Equal(t, "vpc-test", *tg.VpcId)
assert.Equal(t, "/", *tg.HealthCheckPath)
assert.Equal(t, "200", *tg.Matcher.HttpCode)
}
func TestCreateTargetGroup_CustomHealthCheck(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("custom-hc"),
HealthCheckPath: aws.String("/healthz"),
HealthCheckIntervalSeconds: aws.Int64(10),
HealthyThresholdCount: aws.Int64(2),
Matcher: &elbv2.Matcher{HttpCode: aws.String("200-299")},
}, testAccountID)
require.NoError(t, err)
tg := out.TargetGroups[0]
assert.Equal(t, "/healthz", *tg.HealthCheckPath)
assert.Equal(t, int64(10), *tg.HealthCheckIntervalSeconds)
assert.Equal(t, int64(2), *tg.HealthyThresholdCount)
assert.Equal(t, "200-299", *tg.Matcher.HttpCode)
}
func TestCreateTargetGroup_DuplicateName(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("dup-tg"),
VpcId: aws.String("vpc-1"),
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("dup-tg"),
VpcId: aws.String("vpc-1"),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "DuplicateTargetGroupName")
}
func TestDeleteTargetGroup(t *testing.T) {
svc := setupTestService(t)
out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("del-tg")}, testAccountID)
require.NoError(t, err)
_, err = svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
TargetGroupArn: out.TargetGroups[0].TargetGroupArn,
}, testAccountID)
require.NoError(t, err)
}
func TestDeleteTargetGroup_InUse(t *testing.T) {
svc := setupTestService(t)
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("inuse-tg")}, testAccountID)
require.NoError(t, err)
lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("inuse-lb")}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
}, testAccountID)
require.NoError(t, err)
_, err = svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "ResourceInUse")
}
func TestDeleteTargetGroup_NotFound(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/nope/xyz"),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "TargetGroupNotFound")
}
func TestDescribeTargetGroups_FilterByLBArn(t *testing.T) {
svc := setupTestService(t)
tg1, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-linked")}, testAccountID)
_, _ = svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-unlinked")}, testAccountID)
lb, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-filter")}, testAccountID)
_, _ = svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg1.TargetGroups[0].TargetGroupArn}},
}, testAccountID)
desc, err := svc.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.TargetGroups, 1)
assert.Equal(t, "tg-linked", *desc.TargetGroups[0].TargetGroupName)
}
// --- Target registration tests ---
func TestRegisterTargets(t *testing.T) {
svc := setupTestService(t)
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("reg-tg"),
Port: aws.Int64(80),
}, testAccountID)
require.NoError(t, err)
_, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
Targets: []*elbv2.TargetDescription{
{Id: aws.String("i-aaa111")},
{Id: aws.String("i-bbb222"), Port: aws.Int64(8080)},
},
}, testAccountID)
require.NoError(t, err)
// Verify via DescribeTargetHealth
health, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
}, testAccountID)
require.NoError(t, err)
require.Len(t, health.TargetHealthDescriptions, 2)
// First target should use TG default port
assert.Equal(t, "i-aaa111", *health.TargetHealthDescriptions[0].Target.Id)
assert.Equal(t, int64(80), *health.TargetHealthDescriptions[0].Target.Port)
assert.Equal(t, "initial", *health.TargetHealthDescriptions[0].TargetHealth.State)
// Second target should use override port
assert.Equal(t, "i-bbb222", *health.TargetHealthDescriptions[1].Target.Id)
assert.Equal(t, int64(8080), *health.TargetHealthDescriptions[1].Target.Port)
}
func TestRegisterTargets_Idempotent(t *testing.T) {
svc := setupTestService(t)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("idem-tg")}, testAccountID)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
targets := []*elbv2.TargetDescription{{Id: aws.String("i-same")}}
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{TargetGroupArn: tgArn, Targets: targets}, testAccountID)
require.NoError(t, err)
_, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{TargetGroupArn: tgArn, Targets: targets}, testAccountID)
require.NoError(t, err)
health, _ := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{TargetGroupArn: tgArn}, testAccountID)
assert.Len(t, health.TargetHealthDescriptions, 1)
}
func TestDeregisterTargets(t *testing.T) {
svc := setupTestService(t)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dereg-tg"), Port: aws.Int64(80)}, testAccountID)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{
{Id: aws.String("i-keep")},
{Id: aws.String("i-remove")},
},
}, testAccountID)
_, err := svc.DeregisterTargets(&elbv2.DeregisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-remove")}},
}, testAccountID)
require.NoError(t, err)
health, _ := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{TargetGroupArn: tgArn}, testAccountID)
require.Len(t, health.TargetHealthDescriptions, 1)
assert.Equal(t, "i-keep", *health.TargetHealthDescriptions[0].Target.Id)
}
func TestDescribeTargetHealth_FilterByTarget(t *testing.T) {
svc := setupTestService(t)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("filter-tg"), Port: aws.Int64(80)}, testAccountID)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
svc.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{
{Id: aws.String("i-one")},
{Id: aws.String("i-two")},
},
}, testAccountID)
health, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-one")}},
}, testAccountID)
require.NoError(t, err)
require.Len(t, health.TargetHealthDescriptions, 1)
assert.Equal(t, "i-one", *health.TargetHealthDescriptions[0].Target.Id)
}
func TestDescribeTargetHealth_TGNotFound(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
TargetGroupArn: aws.String("arn:nonexistent"),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "TargetGroupNotFound")
}
// --- Listener tests ---
func TestCreateListener(t *testing.T) {
svc := setupTestService(t)
lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lst-lb")}, testAccountID)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("lst-tg")}, testAccountID)
out, err := svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
Protocol: aws.String("HTTP"),
Port: aws.Int64(8080),
DefaultActions: []*elbv2.Action{
{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, out.Listeners, 1)
l := out.Listeners[0]
assert.Equal(t, "HTTP", *l.Protocol)
assert.Equal(t, int64(8080), *l.Port)
assert.Equal(t, *lbOut.LoadBalancers[0].LoadBalancerArn, *l.LoadBalancerArn)
require.Len(t, l.DefaultActions, 1)
assert.Equal(t, "forward", *l.DefaultActions[0].Type)
}
func TestCreateListener_DuplicatePort(t *testing.T) {
svc := setupTestService(t)
lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dup-port-lb")}, testAccountID)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dup-port-tg")}, testAccountID)
actions := []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}}
_, err := svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
Port: aws.Int64(80),
DefaultActions: actions,
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
Port: aws.Int64(80),
DefaultActions: actions,
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "DuplicateListener")
}
func TestCreateListener_LBNotFound(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: aws.String("arn:nonexistent"),
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: aws.String("arn:tg")}},
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "LoadBalancerNotFound")
}
func TestDeleteListener(t *testing.T) {
svc := setupTestService(t)
lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dellst-lb")}, testAccountID)
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dellst-tg")}, testAccountID)
lstOut, err := svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
}, testAccountID)
require.NoError(t, err)
_, err = svc.DeleteListener(&elbv2.DeleteListenerInput{
ListenerArn: lstOut.Listeners[0].ListenerArn,
}, testAccountID)
require.NoError(t, err)
// Verify deleted
desc, _ := svc.DescribeListeners(&elbv2.DescribeListenersInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
}, testAccountID)
assert.Empty(t, desc.Listeners)
}
func TestDeleteListener_NotFound(t *testing.T) {
svc := setupTestService(t)
_, err := svc.DeleteListener(&elbv2.DeleteListenerInput{
ListenerArn: aws.String("arn:nonexistent"),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "ListenerNotFound")
}
func TestDescribeListeners_FilterByLBArn(t *testing.T) {
svc := setupTestService(t)
lb1, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dl-lb1")}, testAccountID)
lb2, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dl-lb2")}, testAccountID)
tg, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dl-tg")}, testAccountID)
actions := []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn}}
svc.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lb1.LoadBalancers[0].LoadBalancerArn, Port: aws.Int64(80), DefaultActions: actions}, testAccountID)
svc.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lb1.LoadBalancers[0].LoadBalancerArn, Port: aws.Int64(443), DefaultActions: actions}, testAccountID)
svc.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lb2.LoadBalancers[0].LoadBalancerArn, Port: aws.Int64(80), DefaultActions: actions}, testAccountID)
desc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{
LoadBalancerArn: lb1.LoadBalancers[0].LoadBalancerArn,
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Listeners, 2)
}
func TestDescribeListeners_AccountIsolation(t *testing.T) {
svc := setupTestService(t)
lb, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("iso-lb")}, testAccountID)
tg, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("iso-tg")}, 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{}, "999999999999")
require.NoError(t, err)
assert.Empty(t, desc.Listeners)
}
// --- HAProxy sync tests ---
func TestCreateListener_PushConfig_NoNATS(t *testing.T) {
// When NATS conn is nil, CreateListener should still succeed
// (pushConfig is a no-op when nc is nil or InstanceID is empty)
svc := setupTestService(t)
lb, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("sync-lb"),
}, testAccountID)
require.NoError(t, err)
tg, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("sync-tg"),
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
Protocol: aws.String("HTTP"),
Port: aws.Int64(80),
DefaultActions: []*elbv2.Action{
{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn},
},
}, testAccountID)
require.NoError(t, err) // No panic, no error — pushConfig skipped gracefully
}
func TestDeleteLoadBalancer_TerminatesALBVM(t *testing.T) {
svc := setupTestService(t)
// Set up a mock instance launcher
mock := &mockTerminateLauncher{}
svc.SetInstanceLauncher(mock)
lb, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("del-lb"),
}, testAccountID)
require.NoError(t, err)
lbArn := *lb.LoadBalancers[0].LoadBalancerArn
// Delete — since no ALB VM was launched (no systemAMI), InstanceID is empty,
// so terminate is not called. This verifies the nil-safe path.
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
LoadBalancerArn: aws.String(lbArn),
}, testAccountID)
require.NoError(t, err)
// No terminate call expected (no instance ID)
assert.Equal(t, 0, len(mock.terminateCalls))
}
// mockTerminateLauncher records TerminateSystemInstance calls for testing.
type mockTerminateLauncher struct {
terminateCalls []string
}
func (m *mockTerminateLauncher) LaunchSystemInstance(_ *SystemInstanceInput) (*SystemInstanceOutput, error) {
return &SystemInstanceOutput{InstanceID: "i-mock", PrivateIP: "10.0.0.1"}, nil
}
func (m *mockTerminateLauncher) TerminateSystemInstance(instanceID string) error {
m.terminateCalls = append(m.terminateCalls, instanceID)
return nil
}
@@ -0,0 +1,264 @@
package handlers_elbv2
import (
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupTestServiceWithVPC creates an ELBv2 service wired to a real VPC service
// with a pre-created VPC and subnet for ENI allocation testing.
func setupTestServiceWithVPC(t *testing.T) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl) {
t.Helper()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
// Create VPC service
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(nil, nc)
require.NoError(t, err)
// Create ELBv2 service with VPC wired in
elbv2Svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
require.NoError(t, err)
elbv2Svc.SetVPCService(vpcSvc)
// Create a VPC and subnet for tests
vpcOut, err := vpcSvc.CreateVpc(&ec2.CreateVpcInput{
CidrBlock: aws.String("10.0.0.0/16"),
}, testAccountID)
require.NoError(t, err)
_, err = vpcSvc.CreateSubnet(&ec2.CreateSubnetInput{
VpcId: vpcOut.Vpc.VpcId,
CidrBlock: aws.String("10.0.1.0/24"),
AvailabilityZone: aws.String("us-east-1a"),
}, testAccountID)
require.NoError(t, err)
return elbv2Svc, vpcSvc
}
// getTestSubnetID creates a fresh subnet and returns its ID.
func getTestSubnetID(t *testing.T, vpcSvc *handlers_ec2_vpc.VPCServiceImpl, vpcID, cidr, az string) string {
t.Helper()
out, err := vpcSvc.CreateSubnet(&ec2.CreateSubnetInput{
VpcId: aws.String(vpcID),
CidrBlock: aws.String(cidr),
AvailabilityZone: aws.String(az),
}, testAccountID)
require.NoError(t, err)
return *out.Subnet.SubnetId
}
func TestCreateLoadBalancer_CreatesENIs(t *testing.T) {
svc, vpcSvc := setupTestServiceWithVPC(t)
// Find the subnet we created
subnets, err := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
require.NoError(t, err)
require.NotEmpty(t, subnets.Subnets)
subnetID := *subnets.Subnets[0].SubnetId
// Create ALB with subnet
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("eni-test-alb"),
Subnets: []*string{aws.String(subnetID)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, out.LoadBalancers, 1)
lb := out.LoadBalancers[0]
// Verify VpcId was populated from subnet
assert.NotEmpty(t, *lb.VpcId)
// Verify AZ info was populated
require.Len(t, lb.AvailabilityZones, 1)
assert.Equal(t, "us-east-1a", *lb.AvailabilityZones[0].ZoneName)
assert.Equal(t, subnetID, *lb.AvailabilityZones[0].SubnetId)
// Verify ENI was created
eniDesc, err := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
require.NoError(t, err)
require.Len(t, eniDesc.NetworkInterfaces, 1)
eni := eniDesc.NetworkInterfaces[0]
assert.Contains(t, *eni.Description, "ELB app/eni-test-alb/")
assert.True(t, *eni.RequesterManaged)
assert.Equal(t, subnetID, *eni.SubnetId)
assert.NotEmpty(t, *eni.PrivateIpAddress)
// Verify ENI has the managed-by tag
foundTag := false
for _, tag := range eni.TagSet {
if *tag.Key == "spinifex:managed-by" && *tag.Value == "elbv2" {
foundTag = true
}
}
assert.True(t, foundTag, "ENI should have spinifex:managed-by=elbv2 tag")
}
func TestCreateLoadBalancer_MultipleSubnets(t *testing.T) {
svc, vpcSvc := setupTestServiceWithVPC(t)
// Get VPC ID
vpcs, _ := vpcSvc.DescribeVpcs(&ec2.DescribeVpcsInput{}, testAccountID)
vpcID := *vpcs.Vpcs[0].VpcId
// Create two subnets in different AZs
sub1 := getTestSubnetID(t, vpcSvc, vpcID, "10.0.2.0/24", "us-east-1a")
sub2 := getTestSubnetID(t, vpcSvc, vpcID, "10.0.3.0/24", "us-east-1b")
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("multi-subnet-alb"),
Subnets: []*string{aws.String(sub1), aws.String(sub2)},
}, testAccountID)
require.NoError(t, err)
lb := out.LoadBalancers[0]
assert.Len(t, lb.AvailabilityZones, 2)
// Verify 2 ENIs created (+ 1 from setupTestServiceWithVPC's initial subnet)
eniDesc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
managedCount := 0
for _, eni := range eniDesc.NetworkInterfaces {
if eni.RequesterManaged != nil && *eni.RequesterManaged {
managedCount++
}
}
assert.Equal(t, 2, managedCount)
}
func TestDeleteLoadBalancer_CleansUpENIs(t *testing.T) {
svc, vpcSvc := setupTestServiceWithVPC(t)
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
subnetID := *subnets.Subnets[0].SubnetId
// Create and then delete ALB
lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("cleanup-alb"),
Subnets: []*string{aws.String(subnetID)},
}, testAccountID)
require.NoError(t, err)
// Verify ENI exists
eniDesc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
assert.Len(t, eniDesc.NetworkInterfaces, 1)
// Delete ALB
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
}, testAccountID)
require.NoError(t, err)
// Verify ENI was cleaned up
eniDesc, _ = vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
assert.Empty(t, eniDesc.NetworkInterfaces)
}
func TestCreateLoadBalancer_InvalidSubnet(t *testing.T) {
svc, _ := setupTestServiceWithVPC(t)
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("bad-subnet-alb"),
Subnets: []*string{aws.String("subnet-nonexistent")},
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "SubnetNotFound")
}
func TestCreateLoadBalancer_RollbackOnPartialFailure(t *testing.T) {
svc, vpcSvc := setupTestServiceWithVPC(t)
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
validSubnet := *subnets.Subnets[0].SubnetId
// First subnet valid, second invalid — should rollback the first ENI
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("rollback-alb"),
Subnets: []*string{aws.String(validSubnet), aws.String("subnet-bogus")},
}, testAccountID)
assert.Error(t, err)
// Verify no orphaned ENIs remain
eniDesc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
assert.Empty(t, eniDesc.NetworkInterfaces)
}
func TestCreateLoadBalancer_WithoutVPCService(t *testing.T) {
// When vpcService is nil (e.g. in pure unit tests), ENI creation is skipped
svc := setupTestService(t)
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("no-vpc-alb"),
Subnets: []*string{aws.String("subnet-xxx")},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.LoadBalancers[0].VpcId)
assert.Empty(t, out.LoadBalancers[0].AvailabilityZones)
}
func TestENI_RequesterManagedFlag(t *testing.T) {
_, vpcSvc := setupTestServiceWithVPC(t)
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
subnetID := *subnets.Subnets[0].SubnetId
// Create a normal ENI (not managed)
normalENI, err := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetID),
Description: aws.String("user ENI"),
}, testAccountID)
require.NoError(t, err)
// Create a managed ENI (like ALB would)
managedENI, err := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetID),
Description: aws.String("ELB app/test/lb123"),
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("network-interface"),
Tags: []*ec2.Tag{
{Key: aws.String("spinifex:managed-by"), Value: aws.String("elbv2")},
},
},
},
}, testAccountID)
require.NoError(t, err)
// Describe all ENIs
desc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
require.Len(t, desc.NetworkInterfaces, 2)
for _, eni := range desc.NetworkInterfaces {
if *eni.NetworkInterfaceId == *normalENI.NetworkInterface.NetworkInterfaceId {
assert.False(t, *eni.RequesterManaged, "normal ENI should not be RequesterManaged")
}
if *eni.NetworkInterfaceId == *managedENI.NetworkInterface.NetworkInterfaceId {
assert.True(t, *eni.RequesterManaged, "managed ENI should be RequesterManaged")
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package handlers_elbv2
import (
"time"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
const defaultTimeout = 30 * time.Second
// NATSELBv2Service handles ELBv2 operations via NATS messaging.
type NATSELBv2Service struct {
natsConn *nats.Conn
}
// NewNATSELBv2Service creates a new NATS-based ELBv2 service.
func NewNATSELBv2Service(conn *nats.Conn) ELBv2Service {
return &NATSELBv2Service{natsConn: conn}
}
func (s *NATSELBv2Service) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, accountID string) (*elbv2.CreateLoadBalancerOutput, error) {
return utils.NATSRequest[elbv2.CreateLoadBalancerOutput](s.natsConn, "elbv2.CreateLoadBalancer", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, accountID string) (*elbv2.DeleteLoadBalancerOutput, error) {
return utils.NATSRequest[elbv2.DeleteLoadBalancerOutput](s.natsConn, "elbv2.DeleteLoadBalancer", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error) {
return utils.NATSRequest[elbv2.DescribeLoadBalancersOutput](s.natsConn, "elbv2.DescribeLoadBalancers", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error) {
return utils.NATSRequest[elbv2.CreateTargetGroupOutput](s.natsConn, "elbv2.CreateTargetGroup", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, accountID string) (*elbv2.DeleteTargetGroupOutput, error) {
return utils.NATSRequest[elbv2.DeleteTargetGroupOutput](s.natsConn, "elbv2.DeleteTargetGroup", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, accountID string) (*elbv2.DescribeTargetGroupsOutput, error) {
return utils.NATSRequest[elbv2.DescribeTargetGroupsOutput](s.natsConn, "elbv2.DescribeTargetGroups", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) RegisterTargets(input *elbv2.RegisterTargetsInput, accountID string) (*elbv2.RegisterTargetsOutput, error) {
return utils.NATSRequest[elbv2.RegisterTargetsOutput](s.natsConn, "elbv2.RegisterTargets", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DeregisterTargets(input *elbv2.DeregisterTargetsInput, accountID string) (*elbv2.DeregisterTargetsOutput, error) {
return utils.NATSRequest[elbv2.DeregisterTargetsOutput](s.natsConn, "elbv2.DeregisterTargets", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DescribeTargetHealth(input *elbv2.DescribeTargetHealthInput, accountID string) (*elbv2.DescribeTargetHealthOutput, error) {
return utils.NATSRequest[elbv2.DescribeTargetHealthOutput](s.natsConn, "elbv2.DescribeTargetHealth", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) CreateListener(input *elbv2.CreateListenerInput, accountID string) (*elbv2.CreateListenerOutput, error) {
return utils.NATSRequest[elbv2.CreateListenerOutput](s.natsConn, "elbv2.CreateListener", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DeleteListener(input *elbv2.DeleteListenerInput, accountID string) (*elbv2.DeleteListenerOutput, error) {
return utils.NATSRequest[elbv2.DeleteListenerOutput](s.natsConn, "elbv2.DeleteListener", input, defaultTimeout, accountID)
}
func (s *NATSELBv2Service) DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error) {
return utils.NATSRequest[elbv2.DescribeListenersOutput](s.natsConn, "elbv2.DescribeListeners", input, defaultTimeout, accountID)
}
@@ -0,0 +1,269 @@
package handlers_elbv2
import (
"encoding/json"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupNATSELBv2Test creates a NATS client + backend service wired together,
// exercising the full NATS serialization round-trip (same as gateway→daemon path).
func setupNATSELBv2Test(t *testing.T) (ELBv2Service, *ELBv2ServiceImpl) {
t.Helper()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
backend, err := NewELBv2ServiceImplWithNATS(nil, nc)
require.NoError(t, err)
// Wire backend as NATS subscriber (simulates daemon subscriptions)
topics := map[string]func(*nats.Msg){
"elbv2.CreateLoadBalancer": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateLoadBalancer) },
"elbv2.DeleteLoadBalancer": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteLoadBalancer) },
"elbv2.DescribeLoadBalancers": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeLoadBalancers) },
"elbv2.CreateTargetGroup": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateTargetGroup) },
"elbv2.DeleteTargetGroup": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteTargetGroup) },
"elbv2.DescribeTargetGroups": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeTargetGroups) },
"elbv2.RegisterTargets": func(msg *nats.Msg) { handleNATSMsg(msg, backend.RegisterTargets) },
"elbv2.DeregisterTargets": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeregisterTargets) },
"elbv2.DescribeTargetHealth": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeTargetHealth) },
"elbv2.CreateListener": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateListener) },
"elbv2.DeleteListener": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteListener) },
"elbv2.DescribeListeners": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeListeners) },
}
for topic, handler := range topics {
sub, err := nc.Subscribe(topic, handler)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
client := NewNATSELBv2Service(nc)
return client, backend
}
// handleNATSMsg is the generic NATS handler that mirrors daemon's handleNATSRequest.
// Uses GenerateErrorPayload for errors, matching the real daemon behavior.
func handleNATSMsg[In any, Out any](msg *nats.Msg, fn func(*In, string) (*Out, error)) {
var input In
if err := json.Unmarshal(msg.Data, &input); err != nil {
_ = msg.Respond(utils.GenerateErrorPayload("ServerInternal"))
return
}
accountID := msg.Header.Get(utils.AccountIDHeader)
result, err := fn(&input, accountID)
if err != nil {
_ = msg.Respond(utils.GenerateErrorPayload(err.Error()))
return
}
data, _ := json.Marshal(result)
_ = msg.Respond(data)
}
// --- Full round-trip E2E tests ---
func TestNATSE2E_CreateAndDescribeLoadBalancer(t *testing.T) {
client, _ := setupNATSELBv2Test(t)
out, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("e2e-alb"),
Subnets: []*string{aws.String("subnet-aaa")},
SecurityGroups: []*string{aws.String("sg-111")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, out.LoadBalancers, 1)
assert.Equal(t, "e2e-alb", *out.LoadBalancers[0].LoadBalancerName)
desc, err := client.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
Names: []*string{aws.String("e2e-alb")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.LoadBalancers, 1)
assert.Equal(t, *out.LoadBalancers[0].LoadBalancerArn, *desc.LoadBalancers[0].LoadBalancerArn)
}
func TestNATSE2E_FullWorkflow(t *testing.T) {
client, _ := setupNATSELBv2Test(t)
// 1. Create target group
tgOut, err := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("e2e-tg"),
Protocol: aws.String("HTTP"),
Port: aws.Int64(80),
VpcId: aws.String("vpc-e2e"),
}, testAccountID)
require.NoError(t, err)
require.Len(t, tgOut.TargetGroups, 1)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
// 2. Register targets
_, err = client.RegisterTargets(&elbv2.RegisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{
{Id: aws.String("i-instance1")},
{Id: aws.String("i-instance2"), Port: aws.Int64(8080)},
},
}, testAccountID)
require.NoError(t, err)
// 3. Verify targets registered
healthOut, err := client.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
TargetGroupArn: tgArn,
}, testAccountID)
require.NoError(t, err)
require.Len(t, healthOut.TargetHealthDescriptions, 2)
assert.Equal(t, "initial", *healthOut.TargetHealthDescriptions[0].TargetHealth.State)
// 4. Create load balancer
lbOut, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("e2e-lb"),
Subnets: []*string{aws.String("subnet-1")},
SecurityGroups: []*string{aws.String("sg-1")},
}, testAccountID)
require.NoError(t, err)
lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
// 5. Create listener
lstOut, err := client.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbArn,
Protocol: aws.String("HTTP"),
Port: aws.Int64(80),
DefaultActions: []*elbv2.Action{
{Type: aws.String("forward"), TargetGroupArn: tgArn},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, lstOut.Listeners, 1)
assert.Equal(t, int64(80), *lstOut.Listeners[0].Port)
// 6. Describe listeners
lstDesc, err := client.DescribeListeners(&elbv2.DescribeListenersInput{
LoadBalancerArn: lbArn,
}, testAccountID)
require.NoError(t, err)
require.Len(t, lstDesc.Listeners, 1)
// 7. Describe target groups filtered by LB
tgDesc, err := client.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{
LoadBalancerArn: lbArn,
}, testAccountID)
require.NoError(t, err)
require.Len(t, tgDesc.TargetGroups, 1)
assert.Equal(t, "e2e-tg", *tgDesc.TargetGroups[0].TargetGroupName)
// 8. Deregister one target
_, err = client.DeregisterTargets(&elbv2.DeregisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-instance1")}},
}, testAccountID)
require.NoError(t, err)
healthOut2, err := client.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
TargetGroupArn: tgArn,
}, testAccountID)
require.NoError(t, err)
require.Len(t, healthOut2.TargetHealthDescriptions, 1)
assert.Equal(t, "i-instance2", *healthOut2.TargetHealthDescriptions[0].Target.Id)
// 9. Delete listener
_, err = client.DeleteListener(&elbv2.DeleteListenerInput{
ListenerArn: lstOut.Listeners[0].ListenerArn,
}, testAccountID)
require.NoError(t, err)
// 10. Delete load balancer
_, err = client.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
LoadBalancerArn: lbArn,
}, testAccountID)
require.NoError(t, err)
// 11. Delete target group (should succeed now — no listener references it)
_, err = client.DeregisterTargets(&elbv2.DeregisterTargetsInput{
TargetGroupArn: tgArn,
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-instance2"), Port: aws.Int64(8080)}},
}, testAccountID)
require.NoError(t, err)
_, err = client.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
TargetGroupArn: tgArn,
}, testAccountID)
require.NoError(t, err)
// 12. Verify everything is cleaned up
lbDesc, err := client.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, testAccountID)
require.NoError(t, err)
assert.Empty(t, lbDesc.LoadBalancers)
tgDesc2, err := client.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{}, testAccountID)
require.NoError(t, err)
assert.Empty(t, tgDesc2.TargetGroups)
}
func TestNATSE2E_DeleteLoadBalancerCascadesListeners(t *testing.T) {
client, _ := setupNATSELBv2Test(t)
// Create LB + TG + 2 listeners
tgOut, _ := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String("cascade-tg"),
}, testAccountID)
tgArn := tgOut.TargetGroups[0].TargetGroupArn
lbOut, _ := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("cascade-lb"),
}, testAccountID)
lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
actions := []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgArn}}
client.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lbArn, Port: aws.Int64(80), DefaultActions: actions}, testAccountID)
client.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lbArn, Port: aws.Int64(443), DefaultActions: actions}, testAccountID)
// Delete LB should cascade-delete listeners
_, err := client.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lbArn}, testAccountID)
require.NoError(t, err)
// TG should now be deletable (no listeners reference it)
_, err = client.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{TargetGroupArn: tgArn}, testAccountID)
require.NoError(t, err)
}
func TestNATSE2E_TargetGroupInUseProtection(t *testing.T) {
client, _ := setupNATSELBv2Test(t)
tgOut, _ := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("protected-tg")}, testAccountID)
lbOut, _ := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("protected-lb")}, testAccountID)
client.CreateListener(&elbv2.CreateListenerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
}, testAccountID)
// Should fail — TG is in use by a listener
_, err := client.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
}, testAccountID)
require.Error(t, err)
}
@@ -0,0 +1,16 @@
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)
}
+299
View File
@@ -0,0 +1,299 @@
package handlers_elbv2
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
const (
KVBucketELBv2 = "spinifex-elbv2"
KVBucketELBv2Version = 1
// Key prefixes for different resource types within the single bucket
KeyPrefixLB = "lb."
KeyPrefixTG = "tg."
KeyPrefixListener = "listener."
)
// Store provides CRUD operations for ELBv2 resources backed by JetStream KV.
type Store struct {
kv nats.KeyValue
}
// NewStore creates a new ELBv2 store using the provided NATS connection.
func NewStore(nc *nats.Conn) (*Store, error) {
js, err := nc.JetStream()
if err != nil {
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
kv, err := utils.GetOrCreateKVBucket(js, KVBucketELBv2, 1)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketELBv2, err)
}
if err := utils.WriteVersion(kv, KVBucketELBv2Version); err != nil {
return nil, fmt.Errorf("write version to %s: %w", KVBucketELBv2, err)
}
slog.Info("ELBv2 store initialized", "bucket", KVBucketELBv2)
return &Store{kv: kv}, nil
}
// --- Load Balancer CRUD ---
// PutLoadBalancer stores a load balancer record.
func (s *Store) PutLoadBalancer(lb *LoadBalancerRecord) error {
data, err := json.Marshal(lb)
if err != nil {
return fmt.Errorf("marshal load balancer: %w", err)
}
_, err = s.kv.Put(KeyPrefixLB+lb.LoadBalancerID, data)
return err
}
// GetLoadBalancer retrieves a load balancer by its short ID.
func (s *Store) GetLoadBalancer(lbID string) (*LoadBalancerRecord, error) {
entry, err := s.kv.Get(KeyPrefixLB + lbID)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return nil, nil
}
return nil, err
}
var lb LoadBalancerRecord
if err := json.Unmarshal(entry.Value(), &lb); err != nil {
return nil, fmt.Errorf("unmarshal load balancer: %w", err)
}
return &lb, nil
}
// DeleteLoadBalancer removes a load balancer by its short ID.
func (s *Store) DeleteLoadBalancer(lbID string) error {
err := s.kv.Delete(KeyPrefixLB + lbID)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
return nil
}
// ListLoadBalancers returns all load balancer records.
func (s *Store) ListLoadBalancers() ([]*LoadBalancerRecord, error) {
return listByPrefix[LoadBalancerRecord](s.kv, KeyPrefixLB)
}
// GetLoadBalancerByArn finds a load balancer by its ARN.
func (s *Store) GetLoadBalancerByArn(arn string) (*LoadBalancerRecord, error) {
lbs, err := s.ListLoadBalancers()
if err != nil {
return nil, err
}
for _, lb := range lbs {
if lb.LoadBalancerArn == arn {
return lb, nil
}
}
return nil, nil
}
// GetLoadBalancerByName finds a load balancer by name.
func (s *Store) GetLoadBalancerByName(name string) (*LoadBalancerRecord, error) {
lbs, err := s.ListLoadBalancers()
if err != nil {
return nil, err
}
for _, lb := range lbs {
if lb.Name == name {
return lb, nil
}
}
return nil, nil
}
// --- Target Group CRUD ---
// PutTargetGroup stores a target group record.
func (s *Store) PutTargetGroup(tg *TargetGroupRecord) error {
data, err := json.Marshal(tg)
if err != nil {
return fmt.Errorf("marshal target group: %w", err)
}
_, err = s.kv.Put(KeyPrefixTG+tg.TargetGroupID, data)
return err
}
// GetTargetGroup retrieves a target group by its short ID.
func (s *Store) GetTargetGroup(tgID string) (*TargetGroupRecord, error) {
entry, err := s.kv.Get(KeyPrefixTG + tgID)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return nil, nil
}
return nil, err
}
var tg TargetGroupRecord
if err := json.Unmarshal(entry.Value(), &tg); err != nil {
return nil, fmt.Errorf("unmarshal target group: %w", err)
}
return &tg, nil
}
// DeleteTargetGroup removes a target group by its short ID.
func (s *Store) DeleteTargetGroup(tgID string) error {
err := s.kv.Delete(KeyPrefixTG + tgID)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
return nil
}
// ListTargetGroups returns all target group records.
func (s *Store) ListTargetGroups() ([]*TargetGroupRecord, error) {
return listByPrefix[TargetGroupRecord](s.kv, KeyPrefixTG)
}
// GetTargetGroupByArn finds a target group by its ARN.
func (s *Store) GetTargetGroupByArn(arn string) (*TargetGroupRecord, error) {
tgs, err := s.ListTargetGroups()
if err != nil {
return nil, err
}
for _, tg := range tgs {
if tg.TargetGroupArn == arn {
return tg, nil
}
}
return nil, nil
}
// GetTargetGroupByName finds a target group by name within a VPC.
func (s *Store) GetTargetGroupByName(name, vpcID string) (*TargetGroupRecord, error) {
tgs, err := s.ListTargetGroups()
if err != nil {
return nil, err
}
for _, tg := range tgs {
if tg.Name == name && tg.VpcId == vpcID {
return tg, nil
}
}
return nil, nil
}
// --- Listener CRUD ---
// PutListener stores a listener record.
func (s *Store) PutListener(l *ListenerRecord) error {
data, err := json.Marshal(l)
if err != nil {
return fmt.Errorf("marshal listener: %w", err)
}
_, err = s.kv.Put(KeyPrefixListener+l.ListenerID, data)
return err
}
// GetListener retrieves a listener by its short ID.
func (s *Store) GetListener(listenerID string) (*ListenerRecord, error) {
entry, err := s.kv.Get(KeyPrefixListener + listenerID)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return nil, nil
}
return nil, err
}
var l ListenerRecord
if err := json.Unmarshal(entry.Value(), &l); err != nil {
return nil, fmt.Errorf("unmarshal listener: %w", err)
}
return &l, nil
}
// DeleteListener removes a listener by its short ID.
func (s *Store) DeleteListener(listenerID string) error {
err := s.kv.Delete(KeyPrefixListener + listenerID)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
return nil
}
// ListListeners returns all listener records.
func (s *Store) ListListeners() ([]*ListenerRecord, error) {
return listByPrefix[ListenerRecord](s.kv, KeyPrefixListener)
}
// ListListenersByLB returns all listeners for a specific load balancer ARN.
func (s *Store) ListListenersByLB(lbArn string) ([]*ListenerRecord, error) {
all, err := s.ListListeners()
if err != nil {
return nil, err
}
var result []*ListenerRecord
for _, l := range all {
if l.LoadBalancerArn == lbArn {
result = append(result, l)
}
}
return result, nil
}
// GetListenerByArn finds a listener by its ARN.
func (s *Store) GetListenerByArn(arn string) (*ListenerRecord, error) {
listeners, err := s.ListListeners()
if err != nil {
return nil, err
}
for _, l := range listeners {
if l.ListenerArn == arn {
return l, nil
}
}
return nil, nil
}
// --- Generic helpers ---
// listByPrefix returns all records with keys matching the given prefix.
func listByPrefix[T any](kv nats.KeyValue, prefix string) ([]*T, error) {
keys, err := kv.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
return nil, nil
}
return nil, err
}
var result []*T
for _, key := range keys {
if key == utils.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
continue
}
entry, err := kv.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
continue
}
return nil, err
}
var record T
if err := json.Unmarshal(entry.Value(), &record); err != nil {
slog.Error("Failed to unmarshal ELBv2 record", "key", key, "err", err)
continue
}
result = append(result, &record)
}
return result, nil
}
+408
View File
@@ -0,0 +1,408 @@
package handlers_elbv2
import (
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAccountID = "123456789012"
func setupTestStore(t *testing.T) *Store {
t.Helper()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
store, err := NewStore(nc)
require.NoError(t, err)
return store
}
func newTestLB(id, name string) *LoadBalancerRecord {
return &LoadBalancerRecord{
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/" + name + "/" + id,
LoadBalancerID: id,
DNSName: name + "-" + id + ".us-east-1.elb.spinifex.local",
Name: name,
Scheme: SchemeInternal,
Type: LoadBalancerTypeApplication,
State: StateActive,
VpcId: "vpc-test123",
SecurityGroups: []string{"sg-111"},
Subnets: []string{"subnet-aaa"},
IPAddressType: IPAddressTypeIPv4,
AccountID: testAccountID,
CreatedAt: time.Now().UTC(),
}
}
func newTestTG(id, name string) *TargetGroupRecord {
return &TargetGroupRecord{
TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":targetgroup/" + name + "/" + id,
TargetGroupID: id,
Name: name,
Protocol: ProtocolHTTP,
Port: 80,
VpcId: "vpc-test123",
TargetType: "instance",
HealthCheck: DefaultHealthCheck(),
AccountID: testAccountID,
CreatedAt: time.Now().UTC(),
}
}
func newTestListener(id, lbArn string) *ListenerRecord {
return &ListenerRecord{
ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":listener/app/my-alb/lb123/" + id,
ListenerID: id,
LoadBalancerArn: lbArn,
Protocol: ProtocolHTTP,
Port: 80,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":targetgroup/my-tg/tg123"},
},
AccountID: testAccountID,
CreatedAt: time.Now().UTC(),
}
}
// --- Load Balancer tests ---
func TestPutAndGetLoadBalancer(t *testing.T) {
store := setupTestStore(t)
lb := newTestLB("abc123", "my-alb")
err := store.PutLoadBalancer(lb)
require.NoError(t, err)
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)
}
func TestGetLoadBalancer_NotFound(t *testing.T) {
store := setupTestStore(t)
got, err := store.GetLoadBalancer("nonexistent")
require.NoError(t, err)
assert.Nil(t, got)
}
func TestStoreDeleteLoadBalancer(t *testing.T) {
store := setupTestStore(t)
lb := newTestLB("del123", "delete-me")
require.NoError(t, store.PutLoadBalancer(lb))
require.NoError(t, store.DeleteLoadBalancer("del123"))
got, err := store.GetLoadBalancer("del123")
require.NoError(t, err)
assert.Nil(t, got)
}
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)
}
func TestGetLoadBalancerByArn(t *testing.T) {
store := setupTestStore(t)
lb := newTestLB("arn123", "arn-test")
require.NoError(t, store.PutLoadBalancer(lb))
got, err := store.GetLoadBalancerByArn(lb.LoadBalancerArn)
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")
require.NoError(t, err)
assert.Nil(t, got)
}
func TestGetLoadBalancerByName(t *testing.T) {
store := setupTestStore(t)
lb := newTestLB("name123", "find-by-name")
require.NoError(t, store.PutLoadBalancer(lb))
got, err := store.GetLoadBalancerByName("find-by-name")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, lb.LoadBalancerID, got.LoadBalancerID)
}
// --- Target Group tests ---
func TestPutAndGetTargetGroup(t *testing.T) {
store := setupTestStore(t)
tg := newTestTG("tg123", "my-tg")
require.NoError(t, store.PutTargetGroup(tg))
got, err := store.GetTargetGroup("tg123")
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)
}
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)
}
func TestGetTargetGroupByArn(t *testing.T) {
store := setupTestStore(t)
tg := newTestTG("tgarn", "arn-tg")
require.NoError(t, store.PutTargetGroup(tg))
got, err := store.GetTargetGroupByArn(tg.TargetGroupArn)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, tg.Name, got.Name)
}
func TestGetTargetGroupByName(t *testing.T) {
store := setupTestStore(t)
tg := newTestTG("tgname", "named-tg")
require.NoError(t, store.PutTargetGroup(tg))
got, err := store.GetTargetGroupByName("named-tg", "vpc-test123")
require.NoError(t, err)
require.NotNil(t, got)
// Wrong VPC should not find it
got, err = store.GetTargetGroupByName("named-tg", "vpc-other")
require.NoError(t, err)
assert.Nil(t, got)
}
func TestTargetGroupWithTargets(t *testing.T) {
store := setupTestStore(t)
tg := newTestTG("tgtargets", "targets-tg")
tg.Targets = []Target{
{Id: "i-aaa111", Port: 8080, HealthState: TargetHealthInitial, PrivateIP: "10.0.1.10"},
{Id: "i-bbb222", Port: 0, HealthState: TargetHealthHealthy, PrivateIP: "10.0.1.11"},
}
require.NoError(t, store.PutTargetGroup(tg))
got, err := store.GetTargetGroup("tgtargets")
require.NoError(t, err)
require.NotNil(t, got)
assert.Len(t, got.Targets, 2)
assert.Equal(t, "i-aaa111", got.Targets[0].Id)
assert.Equal(t, int64(8080), got.Targets[0].Port)
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)
}
func TestListListenersByLB(t *testing.T) {
store := setupTestStore(t)
lbArn1 := "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/alb1/lb1"
lbArn2 := "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/alb2/lb2"
l1 := newTestListener("lst1", lbArn1)
l1.Port = 80
l2 := newTestListener("lst2", lbArn1)
l2.Port = 443
l3 := newTestListener("lst3", lbArn2)
l3.Port = 80
require.NoError(t, store.PutListener(l1))
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)
}
func TestGetListenerByArn(t *testing.T) {
store := setupTestStore(t)
lbArn := "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/test/lb1"
l := newTestListener("lstarn", lbArn)
require.NoError(t, store.PutListener(l))
got, err := store.GetListenerByArn(l.ListenerArn)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, l.ListenerID, got.ListenerID)
}
// --- Cross-resource isolation tests ---
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")
tg := newTestTG("shared1", "tg-shared") // Same ID as LB
l := newTestListener("shared1", lb.LoadBalancerArn)
require.NoError(t, store.PutLoadBalancer(lb))
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)
assert.Equal(t, "alb-shared", gotLB.Name)
gotTG, err := store.GetTargetGroup("shared1")
require.NoError(t, err)
require.NotNil(t, gotTG)
assert.Equal(t, "tg-shared", gotTG.Name)
gotL, err := store.GetListener("shared1")
require.NoError(t, err)
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")
assert.NotNil(t, gotTG)
gotL, _ = store.GetListener("shared1")
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)
}
+143
View File
@@ -0,0 +1,143 @@
package handlers_elbv2
import "time"
const (
// LoadBalancer types
LoadBalancerTypeApplication = "application"
// LoadBalancer schemes
SchemeInternetFacing = "internet-facing"
SchemeInternal = "internal"
// LoadBalancer states
StateProvisioning = "provisioning"
StateActive = "active"
StateFailed = "failed"
// Target health states
TargetHealthInitial = "initial"
TargetHealthHealthy = "healthy"
TargetHealthUnhealthy = "unhealthy"
TargetHealthDraining = "draining"
TargetHealthUnused = "unused"
// Listener protocols
ProtocolHTTP = "HTTP"
ProtocolHTTPS = "HTTPS"
// Listener action types
ActionTypeForward = "forward"
// Default health check values
DefaultHealthCheckInterval = 30
DefaultHealthCheckTimeout = 5
DefaultHealthyThreshold = 5
DefaultUnhealthyThreshold = 2
DefaultHealthCheckPath = "/"
DefaultHealthCheckPort = "traffic-port"
DefaultHealthCheckProtocol = ProtocolHTTP
DefaultHealthCheckMatcher = "200"
DefaultTargetDeregistrationDelaySecs = 300
// IP address type
IPAddressTypeIPv4 = "ipv4"
)
// LoadBalancerRecord represents a stored Application Load Balancer.
type LoadBalancerRecord struct {
LoadBalancerArn string `json:"load_balancer_arn"`
LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
DNSName string `json:"dns_name"`
Name string `json:"name"`
Scheme string `json:"scheme"` // "internet-facing" or "internal"
Type string `json:"type"` // Always "application"
State string `json:"state"` // "provisioning", "active", "failed"
VpcId string `json:"vpc_id"`
SecurityGroups []string `json:"security_groups"`
Subnets []string `json:"subnets"`
AvailZones []AvailZoneInfo `json:"availability_zones"`
ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
NodeID string `json:"node_id"` // Daemon node running this ALB
IPAddressType string `json:"ip_address_type"` // "ipv4"
Tags map[string]string `json:"tags,omitempty"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
}
// AvailZoneInfo tracks subnet-to-AZ mapping for a load balancer.
type AvailZoneInfo struct {
ZoneName string `json:"zone_name"`
SubnetId string `json:"subnet_id"`
}
// TargetGroupRecord represents a stored Target Group.
type TargetGroupRecord struct {
TargetGroupArn string `json:"target_group_arn"`
TargetGroupID string `json:"target_group_id"` // Short ID (hex suffix)
Name string `json:"name"`
Protocol string `json:"protocol"` // "HTTP" or "HTTPS"
Port int64 `json:"port"` // Default target port
VpcId string `json:"vpc_id"`
TargetType string `json:"target_type"` // "instance" for v1
HealthCheck HealthCheckConfig `json:"health_check"`
Targets []Target `json:"targets"`
Tags map[string]string `json:"tags,omitempty"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
}
// HealthCheckConfig defines health check parameters for a target group.
type HealthCheckConfig struct {
Protocol string `json:"protocol"`
Port string `json:"port"` // Port number or "traffic-port"
Path string `json:"path"`
IntervalSeconds int64 `json:"interval_seconds"`
TimeoutSeconds int64 `json:"timeout_seconds"`
HealthyThreshold int64 `json:"healthy_threshold"`
UnhealthyThreshold int64 `json:"unhealthy_threshold"`
Matcher string `json:"matcher"` // HTTP codes e.g. "200" or "200-299"
}
// DefaultHealthCheck returns a HealthCheckConfig with AWS default values.
func DefaultHealthCheck() HealthCheckConfig {
return HealthCheckConfig{
Protocol: DefaultHealthCheckProtocol,
Port: DefaultHealthCheckPort,
Path: DefaultHealthCheckPath,
IntervalSeconds: DefaultHealthCheckInterval,
TimeoutSeconds: DefaultHealthCheckTimeout,
HealthyThreshold: DefaultHealthyThreshold,
UnhealthyThreshold: DefaultUnhealthyThreshold,
Matcher: DefaultHealthCheckMatcher,
}
}
// Target represents a registered target in a target group.
type Target struct {
Id string `json:"id"` // Instance ID (e.g. i-xxxxx)
Port int64 `json:"port"` // Override port (0 = use TG default)
HealthState string `json:"health_state"` // "initial", "healthy", "unhealthy", "draining"
HealthDesc string `json:"health_desc"` // Reason for current state
PrivateIP string `json:"private_ip"` // Resolved from instance ENI
}
// ListenerRecord represents a stored Listener.
type ListenerRecord struct {
ListenerArn string `json:"listener_arn"`
ListenerID string `json:"listener_id"` // Short ID (hex suffix)
LoadBalancerArn string `json:"load_balancer_arn"`
Protocol string `json:"protocol"` // "HTTP" or "HTTPS"
Port int64 `json:"port"`
DefaultActions []ListenerAction `json:"default_actions"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
}
// ListenerAction defines a listener's default action.
type ListenerAction struct {
Type string `json:"type"` // "forward"
TargetGroupArn string `json:"target_group_arn"`
}
+80
View File
@@ -0,0 +1,80 @@
package handlers_elbv2
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDefaultHealthCheck(t *testing.T) {
hc := DefaultHealthCheck()
assert.Equal(t, ProtocolHTTP, hc.Protocol)
assert.Equal(t, DefaultHealthCheckPort, hc.Port)
assert.Equal(t, DefaultHealthCheckPath, hc.Path)
assert.Equal(t, int64(DefaultHealthCheckInterval), hc.IntervalSeconds)
assert.Equal(t, int64(DefaultHealthCheckTimeout), hc.TimeoutSeconds)
assert.Equal(t, int64(DefaultHealthyThreshold), hc.HealthyThreshold)
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")
}
+5
View File
@@ -79,6 +79,11 @@ type VM struct {
// Placement group tracking (set during RunInstances when a placement group is specified)
PlacementGroupName string `json:"placement_group_name,omitempty"`
PlacementGroupNode string `json:"placement_group_node,omitempty"`
// ExtraHostfwdPorts lists additional guest ports to forward from the host
// via the QEMU user-mode dev NIC. Used by ALB VMs to expose HTTP ports.
// Maps guest port → host port (host port filled in by StartInstance).
ExtraHostfwd map[int]int `json:"extra_hostfwd,omitempty"`
}
// ResetNodeLocalState zeroes out fields that are specific to the daemon node
+111
View File
@@ -674,3 +674,114 @@ Standalone VPC networking test suite. Runs against a running Spinifex endpoint (
- `delete-subnet`
- `delete-vpc`
- Verify OVN cleanup (no VPC routers remaining, when OVN available)
---
## ELBv2 / ALB (`run-elbv2-e2e.sh`)
Standalone ELBv2 (Application Load Balancer) E2E test suite. Requires VPC networking
for ALB ENI creation. Configurable endpoint via `ENDPOINT` env var.
### Phase 0: VPC + Subnet Setup
- `create-vpc` (10.200.0.0/16, prerequisite for ALB)
- `create-subnet` (10.200.1.0/24)
### Phase 1: Target Group CRUD
- `create-target-group` (HTTP, port 80, verify health check defaults)
- `describe-target-groups` (by ARN, verify single result)
- `create-target-group` (second TG, port 8080)
- `describe-target-groups` (all, verify >= 2)
- Duplicate name detection (same name → error)
### Phase 2: Target Registration
- `register-targets` (2 fake instances)
- `describe-target-health` (verify 2 targets, initial state)
- `deregister-targets` (remove 1 target)
- `describe-target-health` (verify 1 target remains)
### Phase 3: Load Balancer CRUD
- `create-load-balancer` (ALB with subnet)
- Verify fields: type=application, state=active, DNS name, scheme=internet-facing
- Verify ENIs created (describe-network-interfaces with ELB filter)
- `describe-load-balancers` (by ARN, verify single result)
- Duplicate name detection (same name → error)
### Phase 4: Listener CRUD
- `create-listener` (port 80, forward to TG)
- Verify fields: port=80, protocol=HTTP
- `describe-listeners` (by LB ARN, verify single result)
- Duplicate port detection (same port → error)
### Phase 5: In-Use Protection
- `delete-target-group` while referenced by listener → error (ResourceInUse)
### Phase 6: Listener Deletion
- `delete-listener`
- Verify 0 listeners remain
- `delete-target-group` (now succeeds after listener removed)
### Phase 7: Load Balancer Deletion
- `delete-load-balancer`
- Verify ALB gone from describe
- Verify ENIs cleaned up
- `delete-target-group` (second TG)
### Phase 8: Error Path Tests
- Describe non-existent ALB → empty result
- Delete non-existent ALB → error
- Delete non-existent TG → error
- Create TG without name → error
- Create listener on non-existent ALB → error
---
## ELBv2 / ALB Data Plane (`run-elbv2-dataplane-e2e.sh`)
Data plane test that verifies ALBs actually balance HTTP traffic across instances.
Requires VPC networking (OVN), HAProxy on daemon node, and imported AMI. Designed
for pseudo-multinode or real multi-node environments.
### Phase 0: Prerequisites
- Discover nano instance type
- Discover AMI (must be pre-imported)
### Phase 1: VPC + Subnet Setup
- `create-vpc` (10.201.0.0/16)
- `create-subnet` (10.201.1.0/24)
### Phase 2: Launch Instances
- `run-instances` x2 with cloud-init HTTP responder (Python http.server on port 80)
- Cloud-init serves `{"instance_id": "<hostname>"}` on each instance
- Poll both instances to running state
- Verify both have `PrivateIpAddress` assigned
### Phase 3: Target Group + Registration
- `create-target-group` (HTTP, port 80, health check on /index.html, 5s interval)
- `register-targets` (both instances)
### Phase 4: ALB + Listener
- `create-load-balancer` (ALB with subnet)
- Resolve ALB ENI private IP (via describe-network-interfaces)
- `create-listener` (port 80, forward to target group)
### Phase 5: Wait for Target Health
- Poll `describe-target-health` until both targets healthy (120s timeout)
- Continue even if targets stay in "initial" (HAProxy may still forward)
### Phase 6: Traffic Balancing — Round Robin
- `curl` ALB ENI IP 20 times, parse `instance_id` from JSON response
- Verify responses contain BOTH instance IDs (round-robin distribution)
- Verify success rate >= 50%
### Phase 7: Single Target After Deregister
- `deregister-targets` (remove second instance)
- Wait for HAProxy reload (3s)
- `curl` ALB 20 times
- Verify ONLY the remaining instance responds
- Verify success rate >= 50%
### Phase 8: Re-register + Recovery
- `register-targets` (re-add deregistered instance)
- Wait for HAProxy reload (5s)
- `curl` ALB 20 times
- Verify BOTH instances responding again
+663
View File
@@ -0,0 +1,663 @@
#!/bin/bash
set -e
# ELBv2 (ALB) Data Plane E2E Test
# Verifies that an ALB actually balances HTTP traffic across target instances.
# Requires: VPC networking (OVN), external networking (pool mode).
#
# Architecture:
# - 2 "app" instances run a Python HTTP responder (returns instance ID as JSON)
# - 1 ALB VM (created by CreateLoadBalancer) runs HAProxy inside a pre-baked Alpine image
#
# Traffic is tested from the host via the ALB's public IP. Instances are in a
# public subnet with an IGW route so the ALB VM can health-check them and the
# host can reach the ALB.
#
# Usage:
# ./tests/e2e/run-elbv2-dataplane-e2e.sh # Default endpoint
# ENDPOINT=https://10.11.12.1:9999 ./tests/e2e/run-elbv2-dataplane-e2e.sh # Custom endpoint
cd "$(dirname "$0")/../.."
ENDPOINT="${ENDPOINT:-https://127.0.0.1:9999}"
export AWS_PROFILE=spinifex
AWS_EC2="aws --endpoint-url ${ENDPOINT} ec2"
AWS_ELBV2="aws --endpoint-url ${ENDPOINT} elbv2"
PASSED=0
FAILED=0
pass() {
echo " ✅ $1"
PASSED=$((PASSED + 1))
}
fail() {
echo " ❌ $1"
FAILED=$((FAILED + 1))
}
# Track created resources for cleanup
VPC_ID=""
SUBNET_ID=""
IGW_ID=""
APP_INSTANCE_IDS=()
TG_ARN=""
LB_ARN=""
LISTENER_ARN=""
cleanup() {
local exit_code=$?
echo ""
echo "Cleanup..."
# Delete listener
if [ -n "$LISTENER_ARN" ]; then
echo " Deleting listener..."
$AWS_ELBV2 delete-listener --listener-arn "$LISTENER_ARN" 2>/dev/null || true
fi
# Delete load balancer (cascade-deletes listeners, terminates ALB VM)
if [ -n "$LB_ARN" ]; then
echo " Deleting load balancer..."
$AWS_ELBV2 delete-load-balancer --load-balancer-arn "$LB_ARN" 2>/dev/null || true
fi
# Deregister targets and delete target group
if [ -n "$TG_ARN" ]; then
echo " Deleting target group..."
$AWS_ELBV2 delete-target-group --target-group-arn "$TG_ARN" 2>/dev/null || true
fi
# Terminate instances
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
if [ -n "$inst_id" ]; then
echo " Terminating instance $inst_id..."
$AWS_EC2 terminate-instances --instance-ids "$inst_id" 2>/dev/null || true
fi
done
# Wait for instances to terminate before deleting subnet/VPC
if [ ${#APP_INSTANCE_IDS[@]} -gt 0 ]; then
echo " Waiting for instances to terminate..."
for attempt in $(seq 1 30); do
ALL_TERMINATED=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
STATE=$($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
# Delete key pair
echo " Deleting key pair..."
$AWS_EC2 delete-key-pair --key-name dp-test-key 2>/dev/null || true
# Detach and delete IGW
if [ -n "$IGW_ID" ] && [ -n "$VPC_ID" ]; then
echo " Detaching IGW..."
$AWS_EC2 detach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" 2>/dev/null || true
$AWS_EC2 delete-internet-gateway --internet-gateway-id "$IGW_ID" 2>/dev/null || true
fi
# Delete subnet
if [ -n "$SUBNET_ID" ]; then
echo " Deleting subnet..."
$AWS_EC2 delete-subnet --subnet-id "$SUBNET_ID" 2>/dev/null || true
fi
# Delete VPC
if [ -n "$VPC_ID" ]; then
echo " Deleting VPC..."
$AWS_EC2 delete-vpc --vpc-id "$VPC_ID" 2>/dev/null || true
fi
echo "Cleanup complete"
echo ""
echo "========================================"
echo "ELBv2 Data Plane E2E Results: $PASSED passed, $FAILED failed"
echo "========================================"
if [ $FAILED -gt 0 ]; then
exit 1
fi
exit $exit_code
}
trap cleanup EXIT
echo "========================================"
echo "ELBv2 (ALB) Data Plane E2E Test"
echo "========================================"
echo "Endpoint: $ENDPOINT"
echo ""
# ==========================================
# Phase 0: Prerequisites — Instance Type + AMI Discovery
# ==========================================
echo "Phase 0: Prerequisites"
echo "========================================"
echo "Discovering instance types..."
AVAILABLE_TYPES=$($AWS_EC2 describe-instance-types --query 'InstanceTypes[*].InstanceType' --output text)
INSTANCE_TYPE=$(echo $AVAILABLE_TYPES | tr ' ' '\n' | grep -m1 'nano')
if [ -z "$INSTANCE_TYPE" ] || [ "$INSTANCE_TYPE" == "None" ]; then
echo "ERROR: No nano instance type found"
exit 1
fi
pass "instance type: $INSTANCE_TYPE"
echo "Discovering AMIs..."
# Use the Ubuntu AMI for app instances. The ALB system image (Alpine)
# contains "alb" in its name — skip it and find a general-purpose image.
ALL_IMAGES=$($AWS_EC2 describe-images --output json 2>&1)
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '[.Images[] | select(.Name | test("alb") | not)][0].ImageId // empty')
if [ -z "$AMI_ID" ]; then
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '.Images[0].ImageId // empty')
fi
if [ -z "$AMI_ID" ] || [ "$AMI_ID" == "None" ]; then
echo "ERROR: No AMI found — import an image first"
exit 1
fi
pass "AMI: $AMI_ID"
echo "Creating key pair..."
$AWS_EC2 delete-key-pair --key-name dp-test-key 2>/dev/null || true
KEY_OUTPUT=$($AWS_EC2 create-key-pair --key-name dp-test-key --output json 2>&1) || {
fail "create key pair"
exit 1
}
pass "key pair: dp-test-key"
# ==========================================
# Phase 1: VPC + Public Subnet Setup
# ==========================================
echo ""
echo "Phase 1: VPC + Public Subnet Setup"
echo "========================================"
echo "Creating VPC..."
VPC_OUTPUT=$($AWS_EC2 create-vpc --cidr-block 10.201.0.0/16 --output json) || {
fail "create-vpc"
exit 1
}
VPC_ID=$(echo "$VPC_OUTPUT" | jq -r '.Vpc.VpcId')
pass "create-vpc: $VPC_ID"
echo "Creating internet gateway..."
IGW_OUTPUT=$($AWS_EC2 create-internet-gateway --output json) || {
fail "create-internet-gateway"
exit 1
}
IGW_ID=$(echo "$IGW_OUTPUT" | jq -r '.InternetGateway.InternetGatewayId')
$AWS_EC2 attach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" || {
fail "attach-internet-gateway"
exit 1
}
pass "internet gateway: $IGW_ID (attached)"
echo "Creating public subnet..."
SUBNET_OUTPUT=$($AWS_EC2 create-subnet --vpc-id "$VPC_ID" --cidr-block 10.201.1.0/24 --output json) || {
fail "create-subnet"
exit 1
}
SUBNET_ID=$(echo "$SUBNET_OUTPUT" | jq -r '.Subnet.SubnetId')
pass "create-subnet: $SUBNET_ID"
# IGW attachment makes the subnet public — Spinifex routes via OVN/br-ext automatically.
pass "public subnet configured (IGW attached)"
# ==========================================
# Phase 2: Launch App Instances
# ==========================================
echo ""
echo "Phase 2: Launch App Instances"
echo "========================================"
# Cloud-init user data for app instances: start a minimal HTTP server that
# responds with the instance ID. The instance ID is discovered from the hostname
# (Spinifex sets hostname to instance ID).
APP_USER_DATA=$(cat <<'USERDATA'
#!/bin/bash
INSTANCE_ID=$(hostname)
mkdir -p /tmp/httpd
echo "{\"instance_id\": \"${INSTANCE_ID}\"}" > /tmp/httpd/index.html
cd /tmp/httpd
nohup python3 -m http.server 80 --bind 0.0.0.0 > /dev/null 2>&1 &
USERDATA
)
echo "Launching 2 app instances with HTTP responder..."
for i in 1 2; do
echo " Launching app instance $i..."
RUN_OUTPUT=$($AWS_EC2 run-instances \
--image-id "$AMI_ID" \
--instance-type "$INSTANCE_TYPE" \
--key-name dp-test-key \
--subnet-id "$SUBNET_ID" \
--user-data "$APP_USER_DATA" \
--output json 2>&1) || {
fail "run-instances (app $i)"
echo " Output: $RUN_OUTPUT"
exit 1
}
INST_ID=$(echo "$RUN_OUTPUT" | jq -r '.Instances[0].InstanceId')
INST_IP=$(echo "$RUN_OUTPUT" | jq -r '.Instances[0].PrivateIpAddress // empty')
if [ -z "$INST_ID" ] || [ "$INST_ID" == "null" ]; then
fail "run-instances (app $i) — no instance ID"
exit 1
fi
APP_INSTANCE_IDS+=("$INST_ID")
echo " App instance $i: $INST_ID (IP: ${INST_IP:-pending})"
sleep 1
done
pass "launched ${#APP_INSTANCE_IDS[@]} app instances"
# Poll instances to running state
echo "Waiting for instances to reach running state..."
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
for attempt in $(seq 1 60); do
STATE=$($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 did not reach running (stuck in $STATE)"
exit 1
fi
sleep 2
done
done
pass "all instances running"
# Collect app instance private IPs
echo "Collecting app instance private IPs..."
declare -A INSTANCE_IPS
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
IP=$($AWS_EC2 describe-instances --instance-ids "$inst_id" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
if [ -z "$IP" ] || [ "$IP" == "None" ]; then
fail "instance $inst_id has no PrivateIpAddress"
exit 1
fi
INSTANCE_IPS[$inst_id]="$IP"
echo " $inst_id -> $IP"
done
pass "all app instances have private IPs"
# Wait for cloud-init + user-data HTTP server to start.
echo "Waiting for cloud-init to complete (~100s on t3.nano with Ubuntu)..."
sleep 100
# ==========================================
# Phase 3: Create Target Group + Register Targets
# ==========================================
echo ""
echo "Phase 3: Target Group + Registration"
echo "========================================"
echo "Creating target group..."
TG_OUTPUT=$($AWS_ELBV2 create-target-group \
--name dp-test-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 2>&1) || {
fail "create-target-group"
echo " Output: $TG_OUTPUT"
exit 1
}
TG_ARN=$(echo "$TG_OUTPUT" | jq -r '.TargetGroups[0].TargetGroupArn')
pass "create-target-group: $TG_ARN"
echo "Registering both app instances as targets..."
$AWS_ELBV2 register-targets \
--target-group-arn "$TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" \
--output json 2>&1 || {
fail "register-targets"
exit 1
}
pass "registered 2 targets"
# ==========================================
# Phase 4: Create ALB + Listener
# ==========================================
echo ""
echo "Phase 4: ALB + Listener"
echo "========================================"
echo "Creating ALB..."
LB_OUTPUT=$($AWS_ELBV2 create-load-balancer \
--name dp-test-alb \
--subnets "$SUBNET_ID" \
--output json 2>&1) || {
fail "create-load-balancer"
echo " Output: $LB_OUTPUT"
exit 1
}
LB_ARN=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].LoadBalancerArn')
LB_STATE=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].State.Code')
pass "create-load-balancer: $LB_ARN (state: $LB_STATE)"
# Discover the ALB's reachable address. With dev_networking, the ALB VM has
# port 80 forwarded to a random host port via QEMU hostfwd. Without it, use
# the public IP from the external networking pool.
LB_ID=$(echo "$LB_ARN" | sed 's|.*/||')
LB_NAME="dp-test-alb"
echo "Discovering ALB address..."
ALB_URL=""
# First try: find the ALB VM's hostfwd port for port 80 (dev_networking mode)
# The ALB VM instance ID can be found from the LB record or QEMU process list.
sleep 3
ALB_QEMU=$(ps auxw 2>/dev/null | grep "qemu-system" | grep -v grep | grep "hostfwd=.*-:80" | tail -1)
if [ -n "$ALB_QEMU" ]; then
ALB_HOST_PORT=$(echo "$ALB_QEMU" | grep -oP 'hostfwd=tcp:[^:]+:\K[0-9]+(?=-:80)' | head -1)
if [ -n "$ALB_HOST_PORT" ]; then
ALB_URL="http://127.0.0.1:${ALB_HOST_PORT}"
pass "ALB reachable via hostfwd: $ALB_URL"
fi
fi
# Second try: ALB ENI public IP (external networking mode)
if [ -z "$ALB_URL" ]; then
ENI_OUTPUT=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=ELB app/${LB_NAME}/${LB_ID}" \
--output json 2>/dev/null)
ALB_PUBLIC_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty' 2>/dev/null)
ALB_PRIVATE_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].PrivateIpAddress // empty' 2>/dev/null)
if [ -n "$ALB_PUBLIC_IP" ] && [ "$ALB_PUBLIC_IP" != "null" ]; then
ALB_URL="http://${ALB_PUBLIC_IP}:80"
pass "ALB reachable via public IP: $ALB_URL"
elif [ -n "$ALB_PRIVATE_IP" ]; then
ALB_URL="http://${ALB_PRIVATE_IP}:80"
pass "ALB private IP: $ALB_PRIVATE_IP (may not be reachable from host)"
fi
fi
if [ -z "$ALB_URL" ]; then
fail "could not discover ALB address"
exit 1
fi
echo "Creating listener (port 80 -> target group)..."
LISTENER_OUTPUT=$($AWS_ELBV2 create-listener \
--load-balancer-arn "$LB_ARN" \
--protocol HTTP \
--port 80 \
--default-actions "Type=forward,TargetGroupArn=$TG_ARN" \
--output json 2>&1) || {
fail "create-listener"
echo " Output: $LISTENER_OUTPUT"
exit 1
}
LISTENER_ARN=$(echo "$LISTENER_OUTPUT" | jq -r '.Listeners[0].ListenerArn')
pass "create-listener: $LISTENER_ARN"
# Wait for ALB to become active (Alpine VM boots fast, ~60s total)
echo "Waiting for ALB to become active (agent ping)..."
ALB_ACTIVE=false
for attempt in $(seq 1 90); do
LB_STATE=$($AWS_ELBV2 describe-load-balancers --load-balancer-arns "$LB_ARN" \
--query 'LoadBalancers[0].State.Code' --output text 2>/dev/null)
if [ "$LB_STATE" == "active" ]; then
ALB_ACTIVE=true
break
fi
if [ $((attempt % 10)) -eq 0 ]; then
echo " ALB state: $LB_STATE (attempt $attempt/90)"
fi
sleep 3
done
if [ "$ALB_ACTIVE" = true ]; then
pass "ALB state: active (agent connected)"
else
fail "ALB did not reach active state (stuck in $LB_STATE)"
exit 1
fi
# ==========================================
# Phase 5: Connectivity Pre-Check (from host)
# ==========================================
echo ""
echo "Phase 5: Connectivity Pre-Check"
echo "========================================"
echo "Testing connectivity from host to ALB at $ALB_URL..."
CONNECTIVITY_OK=false
for attempt in $(seq 1 20); do
if curl -s --max-time 3 "$ALB_URL/" 2>/dev/null | grep -q "instance_id"; then
CONNECTIVITY_OK=true
break
fi
echo " Attempt $attempt/20: ALB not yet responding..."
sleep 5
done
if [ "$CONNECTIVITY_OK" = true ]; then
pass "host can reach ALB at $ALB_URL"
else
fail "host cannot reach ALB at $ALB_URL"
echo " Debug: trying to curl ALB directly..."
curl -v --max-time 5 "$ALB_URL/" 2>&1 | tail -10
exit 1
fi
# ==========================================
# Phase 6: Wait for Targets to Become Healthy
# ==========================================
echo ""
echo "Phase 6: Wait for Target Health"
echo "========================================"
echo "Polling target health (timeout 120s)..."
HEALTH_TIMEOUT=120
HEALTH_START=$(date +%s)
TARGETS_HEALTHY=false
while true; do
ELAPSED=$(( $(date +%s) - HEALTH_START ))
if [ $ELAPSED -ge $HEALTH_TIMEOUT ]; then
break
fi
HEALTH_OUTPUT=$($AWS_ELBV2 describe-target-health \
--target-group-arn "$TG_ARN" \
--output json 2>/dev/null) || continue
HEALTHY_COUNT=$(echo "$HEALTH_OUTPUT" | jq '[.TargetHealthDescriptions[] | select(.TargetHealth.State == "healthy")] | length')
TOTAL_COUNT=$(echo "$HEALTH_OUTPUT" | jq '.TargetHealthDescriptions | length')
echo " ${ELAPSED}s: $HEALTHY_COUNT/$TOTAL_COUNT targets healthy"
if [ "$HEALTHY_COUNT" -eq 2 ]; then
TARGETS_HEALTHY=true
break
fi
sleep 5
done
if [ "$TARGETS_HEALTHY" = true ]; then
pass "both targets healthy"
else
echo " Current target health:"
echo "$HEALTH_OUTPUT" | jq -r '.TargetHealthDescriptions[] | " \(.Target.Id): \(.TargetHealth.State) (\(.TargetHealth.Reason // "n/a"))"'
# Health state reporting is not yet implemented — HAProxy does its own health
# checks. The traffic test below verifies actual connectivity.
echo " ⚠️ Target health API not yet reporting — skipping (HAProxy health checks work independently)"
fi
# ==========================================
# Phase 7: Traffic Balancing — Round Robin
# ==========================================
echo ""
echo "Phase 7: Traffic Balancing"
echo "========================================"
NUM_REQUESTS=20
echo "Sending $NUM_REQUESTS requests to ALB at $ALB_URL ..."
declare -A RESPONSE_COUNTS
TOTAL_SUCCESS=0
TOTAL_FAIL=0
for i in $(seq 1 $NUM_REQUESTS); do
RESPONSE=$(curl -s --max-time 5 "$ALB_URL/" 2>/dev/null) || {
TOTAL_FAIL=$((TOTAL_FAIL + 1))
continue
}
RESP_INSTANCE=$(echo "$RESPONSE" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$RESP_INSTANCE" ]; then
RESPONSE_COUNTS[$RESP_INSTANCE]=$(( ${RESPONSE_COUNTS[$RESP_INSTANCE]:-0} + 1 ))
TOTAL_SUCCESS=$((TOTAL_SUCCESS + 1))
else
TOTAL_FAIL=$((TOTAL_FAIL + 1))
fi
done
echo " Results: $TOTAL_SUCCESS successful, $TOTAL_FAIL failed"
echo " Distribution:"
for inst_id in "${!RESPONSE_COUNTS[@]}"; do
echo " $inst_id: ${RESPONSE_COUNTS[$inst_id]} responses"
done
# Verify we got responses from BOTH instances
UNIQUE_RESPONDERS=${#RESPONSE_COUNTS[@]}
if [ "$UNIQUE_RESPONDERS" -ge 2 ]; then
pass "round-robin: $UNIQUE_RESPONDERS unique instances responded"
else
fail "round-robin: expected 2 unique responders, got $UNIQUE_RESPONDERS"
fi
if [ "$TOTAL_SUCCESS" -ge $((NUM_REQUESTS / 2)) ]; then
pass "success rate: $TOTAL_SUCCESS/$NUM_REQUESTS requests succeeded"
else
fail "success rate: only $TOTAL_SUCCESS/$NUM_REQUESTS requests succeeded"
fi
# ==========================================
# Phase 8: Deregister One Target — Single Target Verification
# ==========================================
echo ""
echo "Phase 8: Single Target After Deregister"
echo "========================================"
DEREGISTERED_INSTANCE="${APP_INSTANCE_IDS[1]}"
REMAINING_INSTANCE="${APP_INSTANCE_IDS[0]}"
echo "Deregistering $DEREGISTERED_INSTANCE..."
$AWS_ELBV2 deregister-targets \
--target-group-arn "$TG_ARN" \
--targets "Id=$DEREGISTERED_INSTANCE" \
--output json 2>&1 || {
fail "deregister-targets"
}
pass "deregistered $DEREGISTERED_INSTANCE"
# Brief pause for HAProxy to reload
sleep 3
echo "Sending $NUM_REQUESTS requests after deregistration..."
declare -A SINGLE_COUNTS
SINGLE_SUCCESS=0
for i in $(seq 1 $NUM_REQUESTS); do
RESPONSE=$(curl -s --max-time 5 "$ALB_URL/" 2>/dev/null) || continue
RESP_INSTANCE=$(echo "$RESPONSE" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$RESP_INSTANCE" ]; then
SINGLE_COUNTS[$RESP_INSTANCE]=$(( ${SINGLE_COUNTS[$RESP_INSTANCE]:-0} + 1 ))
SINGLE_SUCCESS=$((SINGLE_SUCCESS + 1))
fi
done
echo " Results: $SINGLE_SUCCESS/$NUM_REQUESTS successful"
echo " Distribution:"
for inst_id in "${!SINGLE_COUNTS[@]}"; do
echo " $inst_id: ${SINGLE_COUNTS[$inst_id]} responses"
done
# Verify ONLY the remaining instance responds.
# The HTTP server returns the VM hostname (spinifex-vm-XXXX), which is a prefix
# of the instance ID (i-XXXX...). Extract the short ID to compare.
REMAINING_SHORT=$(echo "$REMAINING_INSTANCE" | sed 's/^i-//' | cut -c1-8)
SINGLE_RESPONDERS=${#SINGLE_COUNTS[@]}
if [ "$SINGLE_RESPONDERS" -eq 1 ]; then
SOLE_RESPONDER="${!SINGLE_COUNTS[@]}"
if echo "$SOLE_RESPONDER" | grep -q "$REMAINING_SHORT"; then
pass "single target: only $REMAINING_INSTANCE responds after deregistration"
else
fail "single target: unexpected responder $SOLE_RESPONDER (expected $REMAINING_INSTANCE)"
fi
else
fail "single target: expected 1 responder, got $SINGLE_RESPONDERS"
fi
if [ "$SINGLE_SUCCESS" -ge $((NUM_REQUESTS / 2)) ]; then
pass "single target success rate: $SINGLE_SUCCESS/$NUM_REQUESTS"
else
fail "single target success rate: only $SINGLE_SUCCESS/$NUM_REQUESTS"
fi
# ==========================================
# Phase 9: Re-register + Verify Recovery
# ==========================================
echo ""
echo "Phase 9: Re-register + Recovery"
echo "========================================"
echo "Re-registering $DEREGISTERED_INSTANCE..."
$AWS_ELBV2 register-targets \
--target-group-arn "$TG_ARN" \
--targets "Id=$DEREGISTERED_INSTANCE" \
--output json 2>&1 || {
fail "re-register-targets"
}
pass "re-registered $DEREGISTERED_INSTANCE"
# Wait for HAProxy reload + target to become routable
sleep 5
echo "Sending $NUM_REQUESTS requests after re-registration..."
declare -A RECOVERY_COUNTS
RECOVERY_SUCCESS=0
for i in $(seq 1 $NUM_REQUESTS); do
RESPONSE=$(curl -s --max-time 5 "$ALB_URL/" 2>/dev/null) || continue
RESP_INSTANCE=$(echo "$RESPONSE" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$RESP_INSTANCE" ]; then
RECOVERY_COUNTS[$RESP_INSTANCE]=$(( ${RECOVERY_COUNTS[$RESP_INSTANCE]:-0} + 1 ))
RECOVERY_SUCCESS=$((RECOVERY_SUCCESS + 1))
fi
done
echo " Distribution:"
for inst_id in "${!RECOVERY_COUNTS[@]}"; do
echo " $inst_id: ${RECOVERY_COUNTS[$inst_id]} responses"
done
RECOVERY_RESPONDERS=${#RECOVERY_COUNTS[@]}
if [ "$RECOVERY_RESPONDERS" -ge 2 ]; then
pass "recovery: both instances responding again ($RECOVERY_RESPONDERS responders)"
else
fail "recovery: expected 2 responders after re-registration, got $RECOVERY_RESPONDERS"
fi
echo ""
+589
View File
@@ -0,0 +1,589 @@
#!/bin/bash
set -e
# ELBv2 (ALB) E2E Test Suite
# Tests Application Load Balancer operations via the AWS CLI against a running Spinifex cluster.
# Requires VPC networking to be functional (ALBs create ENIs in subnets).
#
# Usage:
# ./tests/e2e/run-elbv2-e2e.sh # Run against default endpoint
# ENDPOINT=https://10.11.12.1:9999 ./tests/e2e/run-elbv2-e2e.sh # Custom endpoint
cd "$(dirname "$0")/../.."
ENDPOINT="${ENDPOINT:-https://127.0.0.1:9999}"
export AWS_PROFILE=spinifex
AWS_EC2="aws --endpoint-url ${ENDPOINT} ec2"
AWS_ELBV2="aws --endpoint-url ${ENDPOINT} elbv2"
PASSED=0
FAILED=0
pass() {
echo " ✅ $1"
PASSED=$((PASSED + 1))
}
fail() {
echo " ❌ $1"
FAILED=$((FAILED + 1))
}
# Track created resources for cleanup
VPC_ID=""
SUBNET_ID=""
TG_ARN=""
TG2_ARN=""
LB_ARN=""
LISTENER_ARN=""
cleanup() {
local exit_code=$?
echo ""
echo "Cleanup..."
# Delete listener
if [ -n "$LISTENER_ARN" ]; then
$AWS_ELBV2 delete-listener --listener-arn "$LISTENER_ARN" 2>/dev/null || true
fi
# Delete load balancer (also cascade-deletes listeners)
if [ -n "$LB_ARN" ]; then
$AWS_ELBV2 delete-load-balancer --load-balancer-arn "$LB_ARN" 2>/dev/null || true
fi
# Deregister any targets and delete target groups
if [ -n "$TG_ARN" ]; then
$AWS_ELBV2 delete-target-group --target-group-arn "$TG_ARN" 2>/dev/null || true
fi
if [ -n "$TG2_ARN" ]; then
$AWS_ELBV2 delete-target-group --target-group-arn "$TG2_ARN" 2>/dev/null || true
fi
# Delete subnet
if [ -n "$SUBNET_ID" ]; then
$AWS_EC2 delete-subnet --subnet-id "$SUBNET_ID" 2>/dev/null || true
fi
# Delete VPC
if [ -n "$VPC_ID" ]; then
$AWS_EC2 delete-vpc --vpc-id "$VPC_ID" 2>/dev/null || true
fi
echo "Cleanup complete"
echo ""
echo "========================================"
echo "ELBv2 E2E Results: $PASSED passed, $FAILED failed"
echo "========================================"
if [ $FAILED -gt 0 ]; then
exit 1
fi
exit $exit_code
}
trap cleanup EXIT
echo "========================================"
echo "ELBv2 (ALB) E2E Test Suite"
echo "========================================"
echo "Endpoint: $ENDPOINT"
echo ""
# Phase 0: VPC + Subnet Setup (prerequisite for ALB)
echo "Phase 0: VPC + Subnet Setup"
echo "========================================"
echo "Creating VPC..."
VPC_OUTPUT=$($AWS_EC2 create-vpc --cidr-block 10.200.0.0/16 --output json 2>&1) || {
fail "create-vpc (prerequisite)"
echo " Output: $VPC_OUTPUT"
exit 1
}
VPC_ID=$(echo "$VPC_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Vpc']['VpcId'])" 2>/dev/null) || {
fail "create-vpc (parse VpcId)"
exit 1
}
pass "create-vpc: $VPC_ID"
echo "Creating subnet..."
SUBNET_OUTPUT=$($AWS_EC2 create-subnet --vpc-id "$VPC_ID" --cidr-block 10.200.1.0/24 --output json 2>&1) || {
fail "create-subnet (prerequisite)"
echo " Output: $SUBNET_OUTPUT"
exit 1
}
SUBNET_ID=$(echo "$SUBNET_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Subnet']['SubnetId'])" 2>/dev/null) || {
fail "create-subnet (parse SubnetId)"
exit 1
}
pass "create-subnet: $SUBNET_ID"
# Phase 1: Target Group CRUD
echo ""
echo "Phase 1: Target Group CRUD"
echo "========================================"
# Create target group
echo "Creating target group..."
TG_OUTPUT=$($AWS_ELBV2 create-target-group \
--name e2e-tg-1 \
--protocol HTTP \
--port 80 \
--vpc-id "$VPC_ID" \
--output json 2>&1) || {
fail "create-target-group"
echo " Output: $TG_OUTPUT"
exit 1
}
TG_ARN=$(echo "$TG_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['TargetGroups'][0]['TargetGroupArn'])" 2>/dev/null) || {
fail "create-target-group (parse ARN)"
echo " Output: $TG_OUTPUT"
exit 1
}
pass "create-target-group: $TG_ARN"
# Verify health check defaults
echo "Verifying health check defaults..."
HC_PATH=$(echo "$TG_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['TargetGroups'][0]['HealthCheckPath'])" 2>/dev/null)
HC_PROTOCOL=$(echo "$TG_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['TargetGroups'][0]['HealthCheckProtocol'])" 2>/dev/null)
if [ "$HC_PATH" = "/" ] && [ "$HC_PROTOCOL" = "HTTP" ]; then
pass "health check defaults: path=/ protocol=HTTP"
else
fail "health check defaults: path=$HC_PATH protocol=$HC_PROTOCOL (expected / HTTP)"
fi
# Describe target groups
echo "Describing target groups..."
DESC_TG_OUTPUT=$($AWS_ELBV2 describe-target-groups --target-group-arns "$TG_ARN" --output json 2>&1) || {
fail "describe-target-groups"
echo " Output: $DESC_TG_OUTPUT"
}
TG_COUNT=$(echo "$DESC_TG_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['TargetGroups']))" 2>/dev/null)
if [ "$TG_COUNT" = "1" ]; then
pass "describe-target-groups: found 1 target group"
else
fail "describe-target-groups: expected 1, got $TG_COUNT"
fi
# Create second target group (for later tests)
echo "Creating second target group..."
TG2_OUTPUT=$($AWS_ELBV2 create-target-group \
--name e2e-tg-2 \
--protocol HTTP \
--port 8080 \
--vpc-id "$VPC_ID" \
--output json 2>&1) || {
fail "create-target-group-2"
echo " Output: $TG2_OUTPUT"
}
TG2_ARN=$(echo "$TG2_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['TargetGroups'][0]['TargetGroupArn'])" 2>/dev/null) || {
fail "create-target-group-2 (parse ARN)"
}
pass "create-target-group-2: $TG2_ARN"
# Describe all target groups (no filter) — should find both
echo "Describing all target groups..."
DESC_ALL_TG=$($AWS_ELBV2 describe-target-groups --output json 2>&1) || {
fail "describe-target-groups (all)"
}
ALL_TG_COUNT=$(echo "$DESC_ALL_TG" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['TargetGroups']))" 2>/dev/null)
if [ "$ALL_TG_COUNT" -ge 2 ]; then
pass "describe-target-groups (all): found $ALL_TG_COUNT target groups"
else
fail "describe-target-groups (all): expected >= 2, got $ALL_TG_COUNT"
fi
# Duplicate name detection
echo "Testing duplicate target group name..."
DUP_OUTPUT=$($AWS_ELBV2 create-target-group \
--name e2e-tg-1 \
--protocol HTTP \
--port 80 \
--vpc-id "$VPC_ID" \
--output json 2>&1) && {
fail "duplicate target group should have been rejected"
} || {
if echo "$DUP_OUTPUT" | grep -qi "DuplicateTargetGroup\|already exists"; then
pass "duplicate target group correctly rejected"
else
pass "duplicate target group rejected (error returned)"
fi
}
# Phase 2: Target Registration
echo ""
echo "Phase 2: Target Registration"
echo "========================================"
# Register fake instance targets (they won't have real IPs but the API should accept them)
echo "Registering targets..."
REG_OUTPUT=$($AWS_ELBV2 register-targets \
--target-group-arn "$TG_ARN" \
--targets Id=i-e2etest00001 Id=i-e2etest00002 \
--output json 2>&1) || {
fail "register-targets"
echo " Output: $REG_OUTPUT"
}
pass "register-targets: 2 instances registered"
# Describe target health
echo "Describing target health..."
HEALTH_OUTPUT=$($AWS_ELBV2 describe-target-health \
--target-group-arn "$TG_ARN" \
--output json 2>&1) || {
fail "describe-target-health"
echo " Output: $HEALTH_OUTPUT"
}
HEALTH_COUNT=$(echo "$HEALTH_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['TargetHealthDescriptions']))" 2>/dev/null)
if [ "$HEALTH_COUNT" = "2" ]; then
pass "describe-target-health: 2 targets reported"
else
fail "describe-target-health: expected 2 targets, got $HEALTH_COUNT"
fi
# Verify target state is "initial" (no real health checks running yet)
FIRST_STATE=$(echo "$HEALTH_OUTPUT" | python3 -c "
import sys,json
d = json.load(sys.stdin)['TargetHealthDescriptions'][0]
print(d.get('TargetHealth', {}).get('State', 'unknown'))
" 2>/dev/null)
if [ "$FIRST_STATE" = "initial" ]; then
pass "target health state: initial (expected)"
else
# Accept any state — might be "unhealthy" if health checks ran
pass "target health state: $FIRST_STATE"
fi
# Deregister one target
echo "Deregistering one target..."
DEREG_OUTPUT=$($AWS_ELBV2 deregister-targets \
--target-group-arn "$TG_ARN" \
--targets Id=i-e2etest00002 \
--output json 2>&1) || {
fail "deregister-targets"
echo " Output: $DEREG_OUTPUT"
}
pass "deregister-targets: removed i-e2etest00002"
# Verify only 1 target remains
HEALTH_OUTPUT=$($AWS_ELBV2 describe-target-health \
--target-group-arn "$TG_ARN" \
--output json 2>&1) || {
fail "describe-target-health (after deregister)"
}
HEALTH_COUNT=$(echo "$HEALTH_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['TargetHealthDescriptions']))" 2>/dev/null)
if [ "$HEALTH_COUNT" = "1" ]; then
pass "describe-target-health after deregister: 1 target remains"
else
fail "describe-target-health after deregister: expected 1, got $HEALTH_COUNT"
fi
# Phase 3: Load Balancer CRUD
echo ""
echo "Phase 3: Load Balancer CRUD"
echo "========================================"
# Create ALB
echo "Creating ALB..."
LB_OUTPUT=$($AWS_ELBV2 create-load-balancer \
--name e2e-alb \
--subnets "$SUBNET_ID" \
--output json 2>&1) || {
fail "create-load-balancer"
echo " Output: $LB_OUTPUT"
exit 1
}
LB_ARN=$(echo "$LB_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['LoadBalancers'][0]['LoadBalancerArn'])" 2>/dev/null) || {
fail "create-load-balancer (parse ARN)"
echo " Output: $LB_OUTPUT"
exit 1
}
pass "create-load-balancer: $LB_ARN"
# Verify ALB fields
echo "Verifying ALB fields..."
LB_TYPE=$(echo "$LB_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['LoadBalancers'][0].get('Type', ''))" 2>/dev/null)
LB_STATE=$(echo "$LB_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['LoadBalancers'][0].get('State', {}).get('Code', ''))" 2>/dev/null)
LB_DNS=$(echo "$LB_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['LoadBalancers'][0].get('DNSName', ''))" 2>/dev/null)
LB_SCHEME=$(echo "$LB_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['LoadBalancers'][0].get('Scheme', ''))" 2>/dev/null)
if [ "$LB_TYPE" = "application" ]; then
pass "ALB type: application"
else
fail "ALB type: expected 'application', got '$LB_TYPE'"
fi
if [ "$LB_STATE" = "active" ]; then
pass "ALB state: active"
else
fail "ALB state: expected 'active', got '$LB_STATE'"
fi
if [ -n "$LB_DNS" ]; then
pass "ALB DNS name: $LB_DNS"
else
fail "ALB DNS name is empty"
fi
if [ "$LB_SCHEME" = "internet-facing" ]; then
pass "ALB scheme: internet-facing (default)"
else
fail "ALB scheme: expected 'internet-facing', got '$LB_SCHEME'"
fi
# Verify ENIs were created
echo "Checking ENIs created for ALB..."
ENI_OUTPUT=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=ELB *" \
--output json 2>&1) || {
fail "describe-network-interfaces (ENI check)"
echo " Output: $ENI_OUTPUT"
}
ENI_COUNT=$(echo "$ENI_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['NetworkInterfaces']))" 2>/dev/null)
if [ "$ENI_COUNT" -ge 1 ]; then
pass "ALB ENIs created: $ENI_COUNT ENI(s) found"
else
fail "ALB ENIs: expected >= 1, got $ENI_COUNT"
fi
# Describe load balancers
echo "Describing load balancers..."
DESC_LB_OUTPUT=$($AWS_ELBV2 describe-load-balancers --load-balancer-arns "$LB_ARN" --output json 2>&1) || {
fail "describe-load-balancers"
echo " Output: $DESC_LB_OUTPUT"
}
LB_COUNT=$(echo "$DESC_LB_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['LoadBalancers']))" 2>/dev/null)
if [ "$LB_COUNT" = "1" ]; then
pass "describe-load-balancers: found 1 ALB"
else
fail "describe-load-balancers: expected 1, got $LB_COUNT"
fi
# Duplicate name detection
echo "Testing duplicate ALB name..."
DUP_LB_OUTPUT=$($AWS_ELBV2 create-load-balancer \
--name e2e-alb \
--subnets "$SUBNET_ID" \
--output json 2>&1) && {
fail "duplicate ALB should have been rejected"
} || {
pass "duplicate ALB correctly rejected"
}
# Phase 4: Listener CRUD
echo ""
echo "Phase 4: Listener CRUD"
echo "========================================"
# Create listener
echo "Creating listener (port 80 -> TG)..."
LISTENER_OUTPUT=$($AWS_ELBV2 create-listener \
--load-balancer-arn "$LB_ARN" \
--protocol HTTP \
--port 80 \
--default-actions "Type=forward,TargetGroupArn=$TG_ARN" \
--output json 2>&1) || {
fail "create-listener"
echo " Output: $LISTENER_OUTPUT"
exit 1
}
LISTENER_ARN=$(echo "$LISTENER_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Listeners'][0]['ListenerArn'])" 2>/dev/null) || {
fail "create-listener (parse ARN)"
echo " Output: $LISTENER_OUTPUT"
exit 1
}
pass "create-listener: $LISTENER_ARN"
# Verify listener fields
LISTENER_PORT=$(echo "$LISTENER_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Listeners'][0].get('Port', 0))" 2>/dev/null)
LISTENER_PROTO=$(echo "$LISTENER_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Listeners'][0].get('Protocol', ''))" 2>/dev/null)
if [ "$LISTENER_PORT" = "80" ] && [ "$LISTENER_PROTO" = "HTTP" ]; then
pass "listener fields: port=80 protocol=HTTP"
else
fail "listener fields: port=$LISTENER_PORT protocol=$LISTENER_PROTO (expected 80 HTTP)"
fi
# Describe listeners
echo "Describing listeners..."
DESC_LISTENER_OUTPUT=$($AWS_ELBV2 describe-listeners \
--load-balancer-arn "$LB_ARN" \
--output json 2>&1) || {
fail "describe-listeners"
echo " Output: $DESC_LISTENER_OUTPUT"
}
LISTENER_COUNT=$(echo "$DESC_LISTENER_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['Listeners']))" 2>/dev/null)
if [ "$LISTENER_COUNT" = "1" ]; then
pass "describe-listeners: found 1 listener"
else
fail "describe-listeners: expected 1, got $LISTENER_COUNT"
fi
# Duplicate listener port detection
echo "Testing duplicate listener port..."
DUP_LISTENER_OUTPUT=$($AWS_ELBV2 create-listener \
--load-balancer-arn "$LB_ARN" \
--protocol HTTP \
--port 80 \
--default-actions "Type=forward,TargetGroupArn=$TG_ARN" \
--output json 2>&1) && {
fail "duplicate listener port should have been rejected"
} || {
pass "duplicate listener port correctly rejected"
}
# Phase 5: In-Use Protection
echo ""
echo "Phase 5: In-Use Protection"
echo "========================================"
# Try to delete target group that's referenced by a listener — should fail
echo "Testing delete target group in use..."
INUSE_OUTPUT=$($AWS_ELBV2 delete-target-group --target-group-arn "$TG_ARN" 2>&1) && {
fail "delete in-use target group should have been rejected"
} || {
if echo "$INUSE_OUTPUT" | grep -qi "InUse\|in use\|ResourceInUse"; then
pass "delete in-use target group correctly rejected (ResourceInUse)"
else
pass "delete in-use target group rejected"
fi
}
# Phase 6: Listener Deletion
echo ""
echo "Phase 6: Listener Deletion"
echo "========================================"
echo "Deleting listener..."
$AWS_ELBV2 delete-listener --listener-arn "$LISTENER_ARN" 2>&1 && {
pass "delete-listener: $LISTENER_ARN"
LISTENER_ARN=""
} || {
fail "delete-listener"
}
# Verify listener is gone
DESC_LISTENER_OUTPUT=$($AWS_ELBV2 describe-listeners \
--load-balancer-arn "$LB_ARN" \
--output json 2>&1) || true
LISTENER_COUNT=$(echo "$DESC_LISTENER_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('Listeners', [])))" 2>/dev/null)
if [ "$LISTENER_COUNT" = "0" ]; then
pass "describe-listeners after delete: 0 listeners"
else
fail "describe-listeners after delete: expected 0, got $LISTENER_COUNT"
fi
# Now the target group should be deletable
echo "Deleting target group (no longer in use)..."
$AWS_ELBV2 delete-target-group --target-group-arn "$TG_ARN" 2>&1 && {
pass "delete-target-group: $TG_ARN (after listener removed)"
TG_ARN=""
} || {
fail "delete-target-group (after listener removed)"
}
# Phase 7: Load Balancer Deletion + Cleanup Verification
echo ""
echo "Phase 7: Load Balancer Deletion"
echo "========================================"
echo "Deleting ALB..."
$AWS_ELBV2 delete-load-balancer --load-balancer-arn "$LB_ARN" 2>&1 && {
pass "delete-load-balancer: $LB_ARN"
} || {
fail "delete-load-balancer"
}
# Verify ALB is gone
DESC_LB_OUTPUT=$($AWS_ELBV2 describe-load-balancers --output json 2>&1) || true
LB_REMAINING=$(echo "$DESC_LB_OUTPUT" | python3 -c "
import sys,json
lbs = json.load(sys.stdin).get('LoadBalancers', [])
print(len([lb for lb in lbs if lb.get('LoadBalancerArn') == '$LB_ARN']))
" 2>/dev/null)
if [ "$LB_REMAINING" = "0" ]; then
pass "ALB deleted: no longer in describe-load-balancers"
else
fail "ALB still exists after deletion"
fi
LB_ARN="" # Don't re-delete in cleanup
# Verify ENIs were cleaned up
echo "Verifying ALB ENIs cleaned up..."
sleep 1 # Brief pause for async cleanup
ENI_OUTPUT=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=ELB *" \
--output json 2>&1) || true
ENI_COUNT=$(echo "$ENI_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('NetworkInterfaces', [])))" 2>/dev/null)
if [ "$ENI_COUNT" = "0" ]; then
pass "ALB ENIs cleaned up: 0 remaining"
else
fail "ALB ENI cleanup: $ENI_COUNT ENIs still exist"
fi
# Delete second target group
echo "Deleting second target group..."
$AWS_ELBV2 delete-target-group --target-group-arn "$TG2_ARN" 2>&1 && {
pass "delete-target-group-2: $TG2_ARN"
TG2_ARN=""
} || {
fail "delete-target-group-2"
}
# Phase 8: Error Path Tests
echo ""
echo "Phase 8: Error Path Tests"
echo "========================================"
# Describe non-existent load balancer
echo "Testing describe non-existent ALB..."
NOTFOUND_OUTPUT=$($AWS_ELBV2 describe-load-balancers \
--load-balancer-arns "arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/fake/fake123" \
--output json 2>&1) || true
NOTFOUND_COUNT=$(echo "$NOTFOUND_OUTPUT" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('LoadBalancers', [])))" 2>/dev/null)
if [ "$NOTFOUND_COUNT" = "0" ]; then
pass "describe non-existent ALB: empty result"
else
fail "describe non-existent ALB: expected 0, got $NOTFOUND_COUNT"
fi
# Delete non-existent load balancer
echo "Testing delete non-existent ALB..."
$AWS_ELBV2 delete-load-balancer \
--load-balancer-arn "arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/fake/fake123" \
2>&1 && {
fail "delete non-existent ALB should have returned error"
} || {
pass "delete non-existent ALB correctly returned error"
}
# Delete non-existent target group
echo "Testing delete non-existent target group..."
$AWS_ELBV2 delete-target-group \
--target-group-arn "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/fake/fake123" \
2>&1 && {
fail "delete non-existent TG should have returned error"
} || {
pass "delete non-existent TG correctly returned error"
}
# Create target group with missing name
echo "Testing create target group with missing fields..."
$AWS_ELBV2 create-target-group --protocol HTTP --port 80 --vpc-id "$VPC_ID" --output json 2>&1 && {
fail "create TG without name should have been rejected"
} || {
pass "create TG without name correctly rejected"
}
# Create listener on non-existent ALB
echo "Testing create listener on non-existent ALB..."
$AWS_ELBV2 create-listener \
--load-balancer-arn "arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/fake/fake123" \
--protocol HTTP \
--port 80 \
--default-actions "Type=forward,TargetGroupArn=$TG2_ARN" \
--output json 2>&1 && {
fail "create listener on non-existent ALB should have been rejected"
} || {
pass "create listener on non-existent ALB correctly rejected"
}
echo ""