From f744aedede40131e25120ee3b56ea6a28d93864d Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Thu, 26 Mar 2026 21:38:14 +1000 Subject: [PATCH] feat: ALB/ELBv2 service --- Makefile | 6 +- cmd/alb-agent/main.go | 62 + scripts/build-alb-image.sh | 206 +++ spinifex/albagent/agent.go | 290 +++++ spinifex/albagent/agent_test.go | 342 +++++ spinifex/awsec2query/parser.go | 21 +- spinifex/awsec2query/parser_test.go | 79 ++ spinifex/awserrors/awserrors.go | 36 + spinifex/awserrors/awserrors_test.go | 17 + spinifex/daemon/daemon.go | 72 +- spinifex/daemon/daemon_handlers_elbv2.go | 51 + spinifex/daemon/daemon_handlers_instance.go | 6 + spinifex/daemon/daemon_system_instance.go | 294 +++++ .../daemon/daemon_system_instance_test.go | 90 ++ spinifex/gateway/elbv2.go | 119 ++ spinifex/gateway/elbv2/CreateListener.go | 33 + spinifex/gateway/elbv2/CreateLoadBalancer.go | 30 + spinifex/gateway/elbv2/CreateTargetGroup.go | 30 + spinifex/gateway/elbv2/DeleteListener.go | 30 + spinifex/gateway/elbv2/DeleteLoadBalancer.go | 30 + spinifex/gateway/elbv2/DeleteTargetGroup.go | 30 + spinifex/gateway/elbv2/DeregisterTargets.go | 33 + spinifex/gateway/elbv2/DescribeListeners.go | 27 + .../gateway/elbv2/DescribeLoadBalancers.go | 27 + .../gateway/elbv2/DescribeTargetGroups.go | 27 + .../gateway/elbv2/DescribeTargetHealth.go | 30 + spinifex/gateway/elbv2/RegisterTargets.go | 33 + spinifex/gateway/elbv2/gateway_elbv2_test.go | 146 +++ spinifex/gateway/elbv2_test.go | 45 + spinifex/gateway/gateway.go | 9 +- spinifex/gateway/gateway_test.go | 1 + .../handlers/ec2/instance/service_impl.go | 77 +- .../ec2/instance/service_impl_test.go | 1 - spinifex/handlers/ec2/vpc/eni.go | 12 +- spinifex/handlers/ec2/vpc/eni_test.go | 29 + spinifex/handlers/elbv2/haproxy.go | 385 ++++++ spinifex/handlers/elbv2/haproxy_test.go | 348 +++++ spinifex/handlers/elbv2/launcher.go | 49 + spinifex/handlers/elbv2/launcher_test.go | 165 +++ spinifex/handlers/elbv2/service.go | 22 + spinifex/handlers/elbv2/service_impl.go | 1123 +++++++++++++++++ .../elbv2/service_impl_health_test.go | 179 +++ spinifex/handlers/elbv2/service_impl_test.go | 643 ++++++++++ .../handlers/elbv2/service_impl_vpc_test.go | 264 ++++ spinifex/handlers/elbv2/service_nats.go | 69 + .../handlers/elbv2/service_nats_e2e_test.go | 269 ++++ spinifex/handlers/elbv2/service_nats_test.go | 16 + spinifex/handlers/elbv2/store.go | 299 +++++ spinifex/handlers/elbv2/store_test.go | 408 ++++++ spinifex/handlers/elbv2/types.go | 143 +++ spinifex/handlers/elbv2/types_test.go | 80 ++ spinifex/vm/vm.go | 5 + tests/e2e/TEST_COVERAGE.md | 111 ++ tests/e2e/run-elbv2-dataplane-e2e.sh | 663 ++++++++++ tests/e2e/run-elbv2-e2e.sh | 589 +++++++++ 55 files changed, 8169 insertions(+), 32 deletions(-) create mode 100644 cmd/alb-agent/main.go create mode 100755 scripts/build-alb-image.sh create mode 100644 spinifex/albagent/agent.go create mode 100644 spinifex/albagent/agent_test.go create mode 100644 spinifex/daemon/daemon_handlers_elbv2.go create mode 100644 spinifex/daemon/daemon_system_instance.go create mode 100644 spinifex/daemon/daemon_system_instance_test.go create mode 100644 spinifex/gateway/elbv2.go create mode 100644 spinifex/gateway/elbv2/CreateListener.go create mode 100644 spinifex/gateway/elbv2/CreateLoadBalancer.go create mode 100644 spinifex/gateway/elbv2/CreateTargetGroup.go create mode 100644 spinifex/gateway/elbv2/DeleteListener.go create mode 100644 spinifex/gateway/elbv2/DeleteLoadBalancer.go create mode 100644 spinifex/gateway/elbv2/DeleteTargetGroup.go create mode 100644 spinifex/gateway/elbv2/DeregisterTargets.go create mode 100644 spinifex/gateway/elbv2/DescribeListeners.go create mode 100644 spinifex/gateway/elbv2/DescribeLoadBalancers.go create mode 100644 spinifex/gateway/elbv2/DescribeTargetGroups.go create mode 100644 spinifex/gateway/elbv2/DescribeTargetHealth.go create mode 100644 spinifex/gateway/elbv2/RegisterTargets.go create mode 100644 spinifex/gateway/elbv2/gateway_elbv2_test.go create mode 100644 spinifex/gateway/elbv2_test.go create mode 100644 spinifex/handlers/elbv2/haproxy.go create mode 100644 spinifex/handlers/elbv2/haproxy_test.go create mode 100644 spinifex/handlers/elbv2/launcher.go create mode 100644 spinifex/handlers/elbv2/launcher_test.go create mode 100644 spinifex/handlers/elbv2/service.go create mode 100644 spinifex/handlers/elbv2/service_impl.go create mode 100644 spinifex/handlers/elbv2/service_impl_health_test.go create mode 100644 spinifex/handlers/elbv2/service_impl_test.go create mode 100644 spinifex/handlers/elbv2/service_impl_vpc_test.go create mode 100644 spinifex/handlers/elbv2/service_nats.go create mode 100644 spinifex/handlers/elbv2/service_nats_e2e_test.go create mode 100644 spinifex/handlers/elbv2/service_nats_test.go create mode 100644 spinifex/handlers/elbv2/store.go create mode 100644 spinifex/handlers/elbv2/store_test.go create mode 100644 spinifex/handlers/elbv2/types.go create mode 100644 spinifex/handlers/elbv2/types_test.go create mode 100755 tests/e2e/run-elbv2-dataplane-e2e.sh create mode 100755 tests/e2e/run-elbv2-e2e.sh diff --git a/Makefile b/Makefile index c750ef3bd..96c74df87 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/alb-agent/main.go b/cmd/alb-agent/main.go new file mode 100644 index 000000000..61ff379c4 --- /dev/null +++ b/cmd/alb-agent/main.go @@ -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() +} diff --git a/scripts/build-alb-image.sh b/scripts/build-alb-image.sh new file mode 100755 index 000000000..0d01d5b5c --- /dev/null +++ b/scripts/build-alb-image.sh @@ -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 < /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 diff --git a/spinifex/albagent/agent.go b/spinifex/albagent/agent.go new file mode 100644 index 000000000..3dcaa8f3e --- /dev/null +++ b/spinifex/albagent/agent.go @@ -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()) +} diff --git a/spinifex/albagent/agent_test.go b/spinifex/albagent/agent_test.go new file mode 100644 index 000000000..5ebe0de0b --- /dev/null +++ b/spinifex/albagent/agent_test.go @@ -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") + } +} diff --git a/spinifex/awsec2query/parser.go b/spinifex/awsec2query/parser.go index 15fb91935..152c41f56 100644 --- a/spinifex/awsec2query/parser.go +++ b/spinifex/awsec2query/parser.go @@ -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() { diff --git a/spinifex/awsec2query/parser_test.go b/spinifex/awsec2query/parser_test.go index 4d9c19174..b37a9ac91 100644 --- a/spinifex/awsec2query/parser_test.go +++ b/spinifex/awsec2query/parser_test.go @@ -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)) +} diff --git a/spinifex/awserrors/awserrors.go b/spinifex/awserrors/awserrors.go index adf1bdd26..78847bfd1 100644 --- a/spinifex/awserrors/awserrors.go +++ b/spinifex/awserrors/awserrors.go @@ -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."}, } diff --git a/spinifex/awserrors/awserrors_test.go b/spinifex/awserrors/awserrors_test.go index ba6b7f565..d197a7154 100644 --- a/spinifex/awserrors/awserrors_test.go +++ b/spinifex/awserrors/awserrors_test.go @@ -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) { diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 7c16326d2..86a39745c 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -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{ diff --git a/spinifex/daemon/daemon_handlers_elbv2.go b/spinifex/daemon/daemon_handlers_elbv2.go new file mode 100644 index 000000000..47ce396da --- /dev/null +++ b/spinifex/daemon/daemon_handlers_elbv2.go @@ -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) +} diff --git a/spinifex/daemon/daemon_handlers_instance.go b/spinifex/daemon/daemon_handlers_instance.go index d2b7666d8..8d6cf4dda 100644 --- a/spinifex/daemon/daemon_handlers_instance.go +++ b/spinifex/daemon/daemon_handlers_instance.go @@ -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) diff --git a/spinifex/daemon/daemon_system_instance.go b/spinifex/daemon/daemon_system_instance.go new file mode 100644 index 000000000..4a96afc6d --- /dev/null +++ b/spinifex/daemon/daemon_system_instance.go @@ -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) +} diff --git a/spinifex/daemon/daemon_system_instance_test.go b/spinifex/daemon/daemon_system_instance_test.go new file mode 100644 index 000000000..c29bd00c5 --- /dev/null +++ b/spinifex/daemon/daemon_system_instance_test.go @@ -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") + } +} diff --git a/spinifex/gateway/elbv2.go b/spinifex/gateway/elbv2.go new file mode 100644 index 000000000..0f50cb996 --- /dev/null +++ b/spinifex/gateway/elbv2.go @@ -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: ... +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 +} diff --git a/spinifex/gateway/elbv2/CreateListener.go b/spinifex/gateway/elbv2/CreateListener.go new file mode 100644 index 000000000..41e8ec099 --- /dev/null +++ b/spinifex/gateway/elbv2/CreateListener.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/CreateLoadBalancer.go b/spinifex/gateway/elbv2/CreateLoadBalancer.go new file mode 100644 index 000000000..3e27ca263 --- /dev/null +++ b/spinifex/gateway/elbv2/CreateLoadBalancer.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/CreateTargetGroup.go b/spinifex/gateway/elbv2/CreateTargetGroup.go new file mode 100644 index 000000000..ef94b4fec --- /dev/null +++ b/spinifex/gateway/elbv2/CreateTargetGroup.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DeleteListener.go b/spinifex/gateway/elbv2/DeleteListener.go new file mode 100644 index 000000000..00462e17b --- /dev/null +++ b/spinifex/gateway/elbv2/DeleteListener.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DeleteLoadBalancer.go b/spinifex/gateway/elbv2/DeleteLoadBalancer.go new file mode 100644 index 000000000..dc930320e --- /dev/null +++ b/spinifex/gateway/elbv2/DeleteLoadBalancer.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DeleteTargetGroup.go b/spinifex/gateway/elbv2/DeleteTargetGroup.go new file mode 100644 index 000000000..90d5553c0 --- /dev/null +++ b/spinifex/gateway/elbv2/DeleteTargetGroup.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DeregisterTargets.go b/spinifex/gateway/elbv2/DeregisterTargets.go new file mode 100644 index 000000000..065591935 --- /dev/null +++ b/spinifex/gateway/elbv2/DeregisterTargets.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DescribeListeners.go b/spinifex/gateway/elbv2/DescribeListeners.go new file mode 100644 index 000000000..bd98657dc --- /dev/null +++ b/spinifex/gateway/elbv2/DescribeListeners.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DescribeLoadBalancers.go b/spinifex/gateway/elbv2/DescribeLoadBalancers.go new file mode 100644 index 000000000..dd86e767e --- /dev/null +++ b/spinifex/gateway/elbv2/DescribeLoadBalancers.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DescribeTargetGroups.go b/spinifex/gateway/elbv2/DescribeTargetGroups.go new file mode 100644 index 000000000..e83e089c6 --- /dev/null +++ b/spinifex/gateway/elbv2/DescribeTargetGroups.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/DescribeTargetHealth.go b/spinifex/gateway/elbv2/DescribeTargetHealth.go new file mode 100644 index 000000000..d06640068 --- /dev/null +++ b/spinifex/gateway/elbv2/DescribeTargetHealth.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/RegisterTargets.go b/spinifex/gateway/elbv2/RegisterTargets.go new file mode 100644 index 000000000..68b4493a7 --- /dev/null +++ b/spinifex/gateway/elbv2/RegisterTargets.go @@ -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 +} diff --git a/spinifex/gateway/elbv2/gateway_elbv2_test.go b/spinifex/gateway/elbv2/gateway_elbv2_test.go new file mode 100644 index 000000000..99a70920b --- /dev/null +++ b/spinifex/gateway/elbv2/gateway_elbv2_test.go @@ -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) +} diff --git a/spinifex/gateway/elbv2_test.go b/spinifex/gateway/elbv2_test.go new file mode 100644 index 000000000..cc10c0f99 --- /dev/null +++ b/spinifex/gateway/elbv2_test.go @@ -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) + } +} diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 1067e266e..385cfe36b 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -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) } diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go index 18e3c9bd0..b50d9afbf 100644 --- a/spinifex/gateway/gateway_test.go +++ b/spinifex/gateway/gateway_test.go @@ -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[""]) diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index 69dbd78f9..52cb910ca 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -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)) diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go index 0cfcb3808..b416f1134 100644 --- a/spinifex/handlers/ec2/instance/service_impl_test.go +++ b/spinifex/handlers/ec2/instance/service_impl_test.go @@ -201,7 +201,6 @@ func TestCloudInitTemplateRendering(t *testing.T) { contains: []string{ "packages:", "nginx", - "custom userdata cloud-config", }, }, { diff --git a/spinifex/handlers/ec2/vpc/eni.go b/spinifex/handlers/ec2/vpc/eni.go index a74c69fa3..44375be4c 100644 --- a/spinifex/handlers/ec2/vpc/eni.go +++ b/spinifex/handlers/ec2/vpc/eni.go @@ -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{ diff --git a/spinifex/handlers/ec2/vpc/eni_test.go b/spinifex/handlers/ec2/vpc/eni_test.go index 5e1b314ae..365099dd9 100644 --- a/spinifex/handlers/ec2/vpc/eni_test.go +++ b/spinifex/handlers/ec2/vpc/eni_test.go @@ -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") diff --git a/spinifex/handlers/elbv2/haproxy.go b/spinifex/handlers/elbv2/haproxy.go new file mode 100644 index 000000000..cf4c24b90 --- /dev/null +++ b/spinifex/handlers/elbv2/haproxy.go @@ -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 +} diff --git a/spinifex/handlers/elbv2/haproxy_test.go b/spinifex/handlers/elbv2/haproxy_test.go new file mode 100644 index 000000000..610430fd7 --- /dev/null +++ b/spinifex/handlers/elbv2/haproxy_test.go @@ -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 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) +} diff --git a/spinifex/handlers/elbv2/launcher.go b/spinifex/handlers/elbv2/launcher.go new file mode 100644 index 000000000..a3ff95862 --- /dev/null +++ b/spinifex/handlers/elbv2/launcher.go @@ -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 +} diff --git a/spinifex/handlers/elbv2/launcher_test.go b/spinifex/handlers/elbv2/launcher_test.go new file mode 100644 index 000000000..b764d08e7 --- /dev/null +++ b/spinifex/handlers/elbv2/launcher_test.go @@ -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) diff --git a/spinifex/handlers/elbv2/service.go b/spinifex/handlers/elbv2/service.go new file mode 100644 index 000000000..477416ed1 --- /dev/null +++ b/spinifex/handlers/elbv2/service.go @@ -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) +} diff --git a/spinifex/handlers/elbv2/service_impl.go b/spinifex/handlers/elbv2/service_impl.go new file mode 100644 index 000000000..8538ce90f --- /dev/null +++ b/spinifex/handlers/elbv2/service_impl.go @@ -0,0 +1,1123 @@ +package handlers_elbv2 + +import ( + "errors" + "fmt" + "log/slog" + "time" + + "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/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/config" + handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" +) + +const ( + // elbv2ManagedByTag is the tag key used to mark ENIs as system-managed by ELBv2. + elbv2ManagedByTag = "spinifex:managed-by" + // elbv2ManagedByValue is the tag value for ELBv2-managed ENIs. + elbv2ManagedByValue = "elbv2" + // elbv2LBTag is the tag key storing the parent LB ARN on managed ENIs. + elbv2LBTag = "spinifex:lb-arn" +) + +// albVMUserData generates cloud-config user data for an ALB VM. +// Uses #cloud-config format so it merges with the base cloud-init config +// instead of going through write_files/runcmd (which requires /bin/bash on the +// base template). HAProxy and alb-agent are pre-installed in the Alpine image +// built by scripts/build-alb-image.sh. +func albVMUserData(lbID, natsURL string) string { + if natsURL == "" { + natsURL = "nats://127.0.0.1:4222" + } + return fmt.Sprintf(`#cloud-config +runcmd: + - [ "sh", "-c", "/usr/local/bin/alb-agent --lb-id=%s --nats=%s > /var/log/alb-agent.log 2>&1 &" ] +`, lbID, natsURL) +} + +// Ensure ELBv2ServiceImpl implements ELBv2Service at compile time. +var _ ELBv2Service = (*ELBv2ServiceImpl)(nil) + +// ELBv2ServiceImpl implements ELBv2 operations with NATS JetStream persistence. +type ELBv2ServiceImpl struct { + config *config.Config + store *Store + nc *nats.Conn // NATS connection for publishing config to ALB VMs + vpcService *handlers_ec2_vpc.VPCServiceImpl // nil-safe: ENI ops skipped when nil (e.g. in tests) + instanceLauncher SystemInstanceLauncher // nil-safe: system VM ops skipped when nil + nodeID string + region string + systemAMI string // AMI ID for system VMs (ALB VMs), set by daemon + natsURL string // NATS URL for ALB VM cloud-init, set by daemon +} + +// NewELBv2ServiceImplWithNATS creates an ELBv2 service backed by JetStream KV. +func NewELBv2ServiceImplWithNATS(cfg *config.Config, nc *nats.Conn) (*ELBv2ServiceImpl, error) { + store, err := NewStore(nc) + if err != nil { + return nil, fmt.Errorf("failed to create ELBv2 store: %w", err) + } + + region := "us-east-1" + nodeID := "" + if cfg != nil { + if cfg.Region != "" { + region = cfg.Region + } + nodeID = cfg.Node + } + + return &ELBv2ServiceImpl{ + config: cfg, + store: store, + nc: nc, + nodeID: nodeID, + region: region, + }, nil +} + +// SetVPCService sets the VPC service for ENI management. Called by the daemon +// after both services are initialized. +func (s *ELBv2ServiceImpl) SetVPCService(vpcService *handlers_ec2_vpc.VPCServiceImpl) { + s.vpcService = vpcService +} + +// SetInstanceLauncher sets the system instance launcher for ALB VM lifecycle. +// Called by the daemon after initialization. +func (s *ELBv2ServiceImpl) SetInstanceLauncher(launcher SystemInstanceLauncher) { + s.instanceLauncher = launcher +} + +// SetSystemAMI sets the AMI ID used for system-managed VMs (ALB VMs). +func (s *ELBv2ServiceImpl) SetSystemAMI(amiID string) { + s.systemAMI = amiID +} + +// SetNATSURL sets the NATS URL that ALB VMs use to connect back to the cluster. +func (s *ELBv2ServiceImpl) SetNATSURL(url string) { + s.natsURL = url +} + + +// resolveENIBindAddr looks up the private IP of the first ENI belonging to the ALB. +// Returns empty string if VPC service is unavailable or no ENIs exist. +func (s *ELBv2ServiceImpl) resolveENIBindAddr(lb *LoadBalancerRecord) string { + if s.vpcService == nil || len(lb.ENIs) == 0 { + return "" + } + + result, err := s.vpcService.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: []*string{aws.String(lb.ENIs[0])}, + }, lb.AccountID) + if err != nil || len(result.NetworkInterfaces) == 0 { + slog.Debug("Could not resolve ALB ENI bind address", "eniId", lb.ENIs[0], "err", err) + return "" + } + + if result.NetworkInterfaces[0].PrivateIpAddress != nil { + return *result.NetworkInterfaces[0].PrivateIpAddress + } + return "" +} + +// waitForAgentReady polls the ALB agent's ping topic until it responds, then +// transitions the LB state from provisioning to active. Runs in a background +// goroutine started by CreateLoadBalancer. +func (s *ELBv2ServiceImpl) waitForAgentReady(lbArn, lbID string) { + pingTopic := "elbv2.alb." + lbID + ".ping" + timeout := 3 * time.Second + + for attempt := 1; attempt <= 90; attempt++ { // up to ~5 minutes + if s.nc == nil { + return + } + msg, err := s.nc.Request(pingTopic, []byte("ping"), timeout) + if err == nil && msg != nil { + slog.Info("ALB agent is ready", "lbId", lbID, "attempt", attempt) + lb, lbErr := s.store.GetLoadBalancerByArn(lbArn) + if lbErr == nil && lb != nil && lb.State == StateProvisioning { + lb.State = StateActive + if putErr := s.store.PutLoadBalancer(lb); putErr != nil { + slog.Error("Failed to update LB state to active", "lbId", lbID, "err", putErr) + } else { + slog.Info("ALB transitioned to active", "lbId", lbID) + } + // Push config now that agent is connected — earlier pushConfig + // calls during CreateListener may have been lost if the agent + // wasn't subscribed yet. + s.pushConfig(lb) + } + return + } + time.Sleep(time.Second) + } + + slog.Warn("ALB agent did not respond within timeout, LB remains in provisioning", "lbId", lbID) +} + +// pushConfig generates the HAProxy config for an ALB and publishes it to the +// ALB VM's NATS config agent. The agent inside the VM writes the config to disk +// and reloads HAProxy. Safe to call when nc or instanceLauncher is nil. +func (s *ELBv2ServiceImpl) pushConfig(lb *LoadBalancerRecord) { + if s.nc == nil || lb.InstanceID == "" { + return + } + + listeners, err := s.store.ListListenersByLB(lb.LoadBalancerArn) + if err != nil { + slog.Error("pushConfig: failed to list listeners", "lbArn", lb.LoadBalancerArn, "err", err) + return + } + + // HAProxy binds to all interfaces (*:port), so no specific bind address needed. + bindAddr := s.resolveENIBindAddr(lb) + + // Collect target groups referenced by listeners + tgByArn := make(map[string]*TargetGroupRecord) + for _, l := range listeners { + for _, a := range l.DefaultActions { + if a.TargetGroupArn == "" { + continue + } + if _, ok := tgByArn[a.TargetGroupArn]; ok { + continue + } + tg, tgErr := s.store.GetTargetGroupByArn(a.TargetGroupArn) + if tgErr != nil || tg == nil { + slog.Debug("pushConfig: target group not found", "tgArn", a.TargetGroupArn) + continue + } + tgByArn[a.TargetGroupArn] = tg + } + } + + // Generate config + configContent, err := GenerateHAProxyConfig(lb, listeners, tgByArn, bindAddr) + if err != nil { + slog.Error("pushConfig: failed to generate config", "lbId", lb.LoadBalancerID, "err", err) + return + } + + // Send config to the ALB VM's NATS agent via request-reply so we know it + // was received and applied. Falls back to fire-and-forget publish if the + // agent hasn't connected yet (e.g. VM still booting). + topic := "elbv2.alb." + lb.LoadBalancerID + ".config" + resp, err := s.nc.Request(topic, []byte(configContent), 10*time.Second) + if err != nil { + slog.Warn("pushConfig: agent did not ACK config (may still be booting), falling back to publish", + "lbId", lb.LoadBalancerID, "topic", topic, "err", err) + if pubErr := s.nc.Publish(topic, []byte(configContent)); pubErr != nil { + slog.Error("pushConfig: publish fallback also failed", "lbId", lb.LoadBalancerID, "err", pubErr) + } + return + } + + slog.Info("pushConfig: config applied by ALB agent", "lbId", lb.LoadBalancerID, "topic", topic, "size", len(configContent), "response", string(resp.Data)) +} + +// pushConfigForTargetGroup finds all LBs that reference the given target group +// (via listeners) and pushes updated config to their ALB VMs. +func (s *ELBv2ServiceImpl) pushConfigForTargetGroup(tgArn, accountID string) { + if s.nc == nil { + return + } + + allListeners, err := s.store.ListListeners() + if err != nil { + slog.Error("pushConfigForTargetGroup: failed to list listeners", "err", err) + return + } + + // Find unique LB ARNs that reference this TG + lbArns := make(map[string]bool) + for _, l := range allListeners { + for _, a := range l.DefaultActions { + if a.TargetGroupArn == tgArn { + lbArns[l.LoadBalancerArn] = true + } + } + } + + for lbArn := range lbArns { + lb, lbErr := s.store.GetLoadBalancerByArn(lbArn) + if lbErr != nil || lb == nil { + continue + } + s.pushConfig(lb) + } +} + +// buildLBArn constructs an ALB ARN from components. +func buildLBArn(region, accountID, name, lbID string) string { + return fmt.Sprintf("arn:aws:elasticloadbalancing:%s:%s:loadbalancer/app/%s/%s", region, accountID, name, lbID) +} + +// resolveTargetIP looks up the private IP for an instance by finding its primary ENI. +// Returns empty string if VPC service is unavailable or no ENI found. +func (s *ELBv2ServiceImpl) resolveTargetIP(instanceID, accountID string) string { + if s.vpcService == nil { + return "" + } + + result, err := s.vpcService.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ + Filters: []*ec2.Filter{ + {Name: aws.String("attachment.instance-id"), Values: []*string{aws.String(instanceID)}}, + }, + }, accountID) + if err != nil || len(result.NetworkInterfaces) == 0 { + slog.Debug("Could not resolve target IP", "instanceId", instanceID, "err", err) + return "" + } + + // Use the first (primary) ENI's private IP + if result.NetworkInterfaces[0].PrivateIpAddress != nil { + return *result.NetworkInterfaces[0].PrivateIpAddress + } + return "" +} + +// buildTGArn constructs a target group ARN from components. +func buildTGArn(region, accountID, name, tgID string) string { + return fmt.Sprintf("arn:aws:elasticloadbalancing:%s:%s:targetgroup/%s/%s", region, accountID, name, tgID) +} + +// buildListenerArn constructs a listener ARN from components. +func buildListenerArn(region, accountID, lbName, lbID, listenerID string) string { + return fmt.Sprintf("arn:aws:elasticloadbalancing:%s:%s:listener/app/%s/%s/%s", region, accountID, lbName, lbID, listenerID) +} + +// --- Load Balancer operations --- + +func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, accountID string) (*elbv2.CreateLoadBalancerOutput, error) { + if input.Name == nil || *input.Name == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + name := *input.Name + + // Check for duplicate name + existing, err := s.store.GetLoadBalancerByName(name) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if existing != nil { + return nil, errors.New(awserrors.ErrorELBv2DuplicateLoadBalancer) + } + + scheme := SchemeInternetFacing + if input.Scheme != nil && *input.Scheme != "" { + scheme = *input.Scheme + if scheme != SchemeInternetFacing && scheme != SchemeInternal { + return nil, errors.New(awserrors.ErrorELBv2InvalidScheme) + } + } + + lbID := utils.GenerateResourceID("lb") + lbArn := buildLBArn(s.region, accountID, name, lbID) + dnsName := fmt.Sprintf("%s-%s.%s.elb.spinifex.local", name, lbID, s.region) + + var subnets []string + for _, sn := range input.Subnets { + if sn != nil { + subnets = append(subnets, *sn) + } + } + + var securityGroups []string + for _, sg := range input.SecurityGroups { + if sg != nil { + securityGroups = append(securityGroups, *sg) + } + } + + tags := make(map[string]string) + for _, tag := range input.Tags { + if tag.Key != nil && tag.Value != nil { + tags[*tag.Key] = *tag.Value + } + } + + // Create ENIs in each subnet (when VPC service is available) + var eniIDs []string + var availZones []AvailZoneInfo + vpcID := "" + if s.vpcService != nil && len(subnets) > 0 { + for _, subnetID := range subnets { + eniOut, eniErr := s.vpcService.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{ + SubnetId: aws.String(subnetID), + Description: aws.String(fmt.Sprintf("ELB app/%s/%s", name, lbID)), + TagSpecifications: []*ec2.TagSpecification{ + { + ResourceType: aws.String("network-interface"), + Tags: []*ec2.Tag{ + {Key: aws.String(elbv2ManagedByTag), Value: aws.String(elbv2ManagedByValue)}, + {Key: aws.String(elbv2LBTag), Value: aws.String(lbArn)}, + }, + }, + }, + }, accountID) + if eniErr != nil { + // Rollback: delete any ENIs already created + for _, rollbackENI := range eniIDs { + s.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{ + NetworkInterfaceId: aws.String(rollbackENI), + }, accountID) + } + slog.Error("CreateLoadBalancer: failed to create ENI", "subnet", subnetID, "err", eniErr) + return nil, errors.New(awserrors.ErrorELBv2SubnetNotFound) + } + + eni := eniOut.NetworkInterface + eniIDs = append(eniIDs, *eni.NetworkInterfaceId) + + if eni.VpcId != nil && vpcID == "" { + vpcID = *eni.VpcId + } + az := "" + if eni.AvailabilityZone != nil { + az = *eni.AvailabilityZone + } + availZones = append(availZones, AvailZoneInfo{ + ZoneName: az, + SubnetId: subnetID, + }) + } + } + + // Launch ALB VM with the first ENI (when instance launcher is available) + var albInstanceID string + var hostPorts map[int]int + if s.instanceLauncher != nil && s.systemAMI != "" && len(eniIDs) > 0 && len(subnets) > 0 { + // Resolve the first ENI's details for the VM + eniIP := "" + eniMAC := "" + if s.vpcService != nil { + result, descErr := s.vpcService.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: []*string{aws.String(eniIDs[0])}, + }, accountID) + if descErr == nil && len(result.NetworkInterfaces) > 0 { + eniIP = aws.StringValue(result.NetworkInterfaces[0].PrivateIpAddress) + eniMAC = aws.StringValue(result.NetworkInterfaces[0].MacAddress) + } + } + + userData := albVMUserData(lbID, s.natsURL) + out, launchErr := s.instanceLauncher.LaunchSystemInstance(&SystemInstanceInput{ + InstanceType: "t3.nano", + ImageID: s.systemAMI, + SubnetID: subnets[0], + UserData: userData, + ENIID: eniIDs[0], + ENIMac: eniMAC, + ENIIP: eniIP, + HostfwdPorts: []int{80, 443}, + }) + if launchErr != nil { + slog.Error("CreateLoadBalancer: failed to launch ALB VM", "lbId", lbID, "err", launchErr) + // Continue without VM — LB is created in provisioning state + } else { + albInstanceID = out.InstanceID + hostPorts = out.HostfwdMap + slog.Info("CreateLoadBalancer: ALB VM launched", "lbId", lbID, "instanceId", albInstanceID, "ip", out.PrivateIP, "hostfwd", hostPorts) + } + } + + // ALB starts in provisioning until the agent inside the VM connects and + // responds to a ping. If no VM is expected (no launcher/AMI), set active. + state := StateActive + if s.instanceLauncher != nil && s.systemAMI != "" { + state = StateProvisioning + } + + record := &LoadBalancerRecord{ + LoadBalancerArn: lbArn, + LoadBalancerID: lbID, + DNSName: dnsName, + Name: name, + Scheme: scheme, + Type: LoadBalancerTypeApplication, + State: state, + VpcId: vpcID, + SecurityGroups: securityGroups, + Subnets: subnets, + AvailZones: availZones, + ENIs: eniIDs, + InstanceID: albInstanceID, + HostPorts: hostPorts, + IPAddressType: IPAddressTypeIPv4, + NodeID: s.nodeID, + Tags: tags, + AccountID: accountID, + CreatedAt: time.Now().UTC(), + } + + if err := s.store.PutLoadBalancer(record); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + // Start background goroutine to wait for ALB agent ping, then transition to active. + if record.State == StateProvisioning && record.InstanceID != "" { + go s.waitForAgentReady(lbArn, lbID) + } + + slog.Info("CreateLoadBalancer completed", "name", name, "lbArn", lbArn, "enis", len(eniIDs), "state", state, "accountID", accountID) + + return &elbv2.CreateLoadBalancerOutput{ + LoadBalancers: []*elbv2.LoadBalancer{s.lbRecordToSDK(record)}, + }, nil +} + +func (s *ELBv2ServiceImpl) DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, accountID string) (*elbv2.DeleteLoadBalancerOutput, error) { + if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if lb == nil { + return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound) + } + + // Terminate ALB VM in background (VM termination also cleans up tap device). + // Must be async because VM shutdown can take seconds (QMP powerdown + QEMU exit) + // and we don't want to block the NATS request handler. + if lb.InstanceID != "" && s.instanceLauncher != nil { + instanceID := lb.InstanceID + go func() { + if err := s.instanceLauncher.TerminateSystemInstance(instanceID); err != nil { + slog.Warn("Failed to terminate ALB VM during LB deletion", "lbId", lb.LoadBalancerID, "instanceId", instanceID, "err", err) + } + }() + } + + // Delete all listeners for this LB + listeners, err := s.store.ListListenersByLB(lb.LoadBalancerArn) + if err != nil { + slog.Warn("Failed to list listeners for cleanup", "lbArn", lb.LoadBalancerArn, "err", err) + } + for _, l := range listeners { + if err := s.store.DeleteListener(l.ListenerID); err != nil { + slog.Warn("Failed to delete listener during LB cleanup", "listenerID", l.ListenerID, "err", err) + } + } + + // Delete system-managed ENIs + if s.vpcService != nil { + for _, eniID := range lb.ENIs { + _, eniErr := s.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{ + NetworkInterfaceId: aws.String(eniID), + }, accountID) + if eniErr != nil { + slog.Warn("Failed to delete ALB ENI during cleanup", "eniId", eniID, "err", eniErr) + } + } + } + + if err := s.store.DeleteLoadBalancer(lb.LoadBalancerID); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + slog.Info("DeleteLoadBalancer completed", "lbArn", *input.LoadBalancerArn, "enis", len(lb.ENIs), "accountID", accountID) + + return &elbv2.DeleteLoadBalancerOutput{}, nil +} + +func (s *ELBv2ServiceImpl) DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error) { + allLBs, err := s.store.ListLoadBalancers() + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + // Build filter sets + arnFilter := make(map[string]bool) + for _, arn := range input.LoadBalancerArns { + if arn != nil { + arnFilter[*arn] = true + } + } + nameFilter := make(map[string]bool) + for _, name := range input.Names { + if name != nil { + nameFilter[*name] = true + } + } + + result := make([]*elbv2.LoadBalancer, 0) + for _, lb := range allLBs { + if lb.AccountID != accountID { + continue + } + if len(arnFilter) > 0 && !arnFilter[lb.LoadBalancerArn] { + continue + } + if len(nameFilter) > 0 && !nameFilter[lb.Name] { + continue + } + result = append(result, s.lbRecordToSDK(lb)) + } + + return &elbv2.DescribeLoadBalancersOutput{ + LoadBalancers: result, + }, nil +} + +// --- Target Group operations --- + +func (s *ELBv2ServiceImpl) CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error) { + if input.Name == nil || *input.Name == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + name := *input.Name + + protocol := ProtocolHTTP + if input.Protocol != nil && *input.Protocol != "" { + protocol = *input.Protocol + } + + port := int64(80) + if input.Port != nil { + port = *input.Port + } + + vpcID := "" + if input.VpcId != nil { + vpcID = *input.VpcId + } + + // Check duplicate name within VPC + existing, err := s.store.GetTargetGroupByName(name, vpcID) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if existing != nil { + return nil, errors.New(awserrors.ErrorELBv2DuplicateTargetGroup) + } + + targetType := "instance" + if input.TargetType != nil && *input.TargetType != "" { + targetType = *input.TargetType + } + + hc := DefaultHealthCheck() + if input.HealthCheckProtocol != nil { + hc.Protocol = *input.HealthCheckProtocol + } + if input.HealthCheckPort != nil { + hc.Port = *input.HealthCheckPort + } + if input.HealthCheckPath != nil { + hc.Path = *input.HealthCheckPath + } + if input.HealthCheckIntervalSeconds != nil { + hc.IntervalSeconds = *input.HealthCheckIntervalSeconds + } + if input.HealthCheckTimeoutSeconds != nil { + hc.TimeoutSeconds = *input.HealthCheckTimeoutSeconds + } + if input.HealthyThresholdCount != nil { + hc.HealthyThreshold = *input.HealthyThresholdCount + } + if input.UnhealthyThresholdCount != nil { + hc.UnhealthyThreshold = *input.UnhealthyThresholdCount + } + if input.Matcher != nil && input.Matcher.HttpCode != nil { + hc.Matcher = *input.Matcher.HttpCode + } + + tgID := utils.GenerateResourceID("tg") + tgArn := buildTGArn(s.region, accountID, name, tgID) + + tags := make(map[string]string) + for _, tag := range input.Tags { + if tag.Key != nil && tag.Value != nil { + tags[*tag.Key] = *tag.Value + } + } + + record := &TargetGroupRecord{ + TargetGroupArn: tgArn, + TargetGroupID: tgID, + Name: name, + Protocol: protocol, + Port: port, + VpcId: vpcID, + TargetType: targetType, + HealthCheck: hc, + Tags: tags, + AccountID: accountID, + CreatedAt: time.Now().UTC(), + } + + if err := s.store.PutTargetGroup(record); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + slog.Info("CreateTargetGroup completed", "name", name, "tgArn", tgArn, "accountID", accountID) + + return &elbv2.CreateTargetGroupOutput{ + TargetGroups: []*elbv2.TargetGroup{s.tgRecordToSDK(record)}, + }, nil +} + +func (s *ELBv2ServiceImpl) DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, accountID string) (*elbv2.DeleteTargetGroupOutput, error) { + if input.TargetGroupArn == nil || *input.TargetGroupArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if tg == nil { + return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound) + } + + // Check if any listener references this target group + listeners, err := s.store.ListListeners() + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + for _, l := range listeners { + for _, action := range l.DefaultActions { + if action.TargetGroupArn == tg.TargetGroupArn { + return nil, errors.New(awserrors.ErrorELBv2TargetGroupInUse) + } + } + } + + if err := s.store.DeleteTargetGroup(tg.TargetGroupID); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + slog.Info("DeleteTargetGroup completed", "tgArn", *input.TargetGroupArn, "accountID", accountID) + + return &elbv2.DeleteTargetGroupOutput{}, nil +} + +func (s *ELBv2ServiceImpl) DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, accountID string) (*elbv2.DescribeTargetGroupsOutput, error) { + allTGs, err := s.store.ListTargetGroups() + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + arnFilter := make(map[string]bool) + for _, arn := range input.TargetGroupArns { + if arn != nil { + arnFilter[*arn] = true + } + } + nameFilter := make(map[string]bool) + for _, name := range input.Names { + if name != nil { + nameFilter[*name] = true + } + } + + result := make([]*elbv2.TargetGroup, 0) + for _, tg := range allTGs { + if tg.AccountID != accountID { + continue + } + if len(arnFilter) > 0 && !arnFilter[tg.TargetGroupArn] { + continue + } + if len(nameFilter) > 0 && !nameFilter[tg.Name] { + continue + } + // Filter by LB ARN if specified + if input.LoadBalancerArn != nil && *input.LoadBalancerArn != "" { + // Check if any listener on this LB references this TG + listeners, _ := s.store.ListListenersByLB(*input.LoadBalancerArn) + found := false + for _, l := range listeners { + for _, a := range l.DefaultActions { + if a.TargetGroupArn == tg.TargetGroupArn { + found = true + } + } + } + if !found { + continue + } + } + result = append(result, s.tgRecordToSDK(tg)) + } + + return &elbv2.DescribeTargetGroupsOutput{ + TargetGroups: result, + }, nil +} + +// --- Target registration --- + +func (s *ELBv2ServiceImpl) RegisterTargets(input *elbv2.RegisterTargetsInput, accountID string) (*elbv2.RegisterTargetsOutput, error) { + if input.TargetGroupArn == nil || *input.TargetGroupArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if tg == nil { + return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound) + } + + // Build map of existing targets for dedup + existing := make(map[string]int) // id:port -> index + for i, t := range tg.Targets { + key := fmt.Sprintf("%s:%d", t.Id, t.Port) + existing[key] = i + } + + for _, td := range input.Targets { + if td.Id == nil { + continue + } + port := tg.Port + if td.Port != nil { + port = *td.Port + } + key := fmt.Sprintf("%s:%d", *td.Id, port) + if _, exists := existing[key]; exists { + continue // Already registered + } + + // Resolve instance ID → private IP via ENI lookup + privateIP := s.resolveTargetIP(*td.Id, accountID) + + tg.Targets = append(tg.Targets, Target{ + Id: *td.Id, + Port: port, + HealthState: TargetHealthInitial, + HealthDesc: "Target registration is in progress", + PrivateIP: privateIP, + }) + } + + if err := s.store.PutTargetGroup(tg); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + // Reload HAProxy for any LBs that reference this target group + s.pushConfigForTargetGroup(tg.TargetGroupArn, accountID) + + slog.Info("RegisterTargets completed", "tgArn", *input.TargetGroupArn, "targetsAdded", len(input.Targets), "accountID", accountID) + + return &elbv2.RegisterTargetsOutput{}, nil +} + +func (s *ELBv2ServiceImpl) DeregisterTargets(input *elbv2.DeregisterTargetsInput, accountID string) (*elbv2.DeregisterTargetsOutput, error) { + if input.TargetGroupArn == nil || *input.TargetGroupArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if tg == nil { + return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound) + } + + // Build removal set + removeSet := make(map[string]bool) + for _, td := range input.Targets { + if td.Id == nil { + continue + } + port := tg.Port + if td.Port != nil { + port = *td.Port + } + removeSet[fmt.Sprintf("%s:%d", *td.Id, port)] = true + } + + var remaining []Target + for _, t := range tg.Targets { + key := fmt.Sprintf("%s:%d", t.Id, t.Port) + if !removeSet[key] { + remaining = append(remaining, t) + } + } + tg.Targets = remaining + + if err := s.store.PutTargetGroup(tg); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + // Reload HAProxy for any LBs that reference this target group + s.pushConfigForTargetGroup(tg.TargetGroupArn, accountID) + + slog.Info("DeregisterTargets completed", "tgArn", *input.TargetGroupArn, "targetsRemoved", len(input.Targets), "accountID", accountID) + + return &elbv2.DeregisterTargetsOutput{}, nil +} + +func (s *ELBv2ServiceImpl) DescribeTargetHealth(input *elbv2.DescribeTargetHealthInput, accountID string) (*elbv2.DescribeTargetHealthOutput, error) { + if input.TargetGroupArn == nil || *input.TargetGroupArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if tg == nil { + return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound) + } + + // Optional: filter to specific targets + targetFilter := make(map[string]bool) + for _, td := range input.Targets { + if td.Id != nil { + targetFilter[*td.Id] = true + } + } + + descriptions := make([]*elbv2.TargetHealthDescription, 0) + for _, t := range tg.Targets { + if len(targetFilter) > 0 && !targetFilter[t.Id] { + continue + } + + desc := &elbv2.TargetHealthDescription{ + Target: &elbv2.TargetDescription{ + Id: aws.String(t.Id), + Port: aws.Int64(t.Port), + }, + TargetHealth: &elbv2.TargetHealth{ + State: aws.String(t.HealthState), + Description: aws.String(t.HealthDesc), + }, + } + descriptions = append(descriptions, desc) + } + + return &elbv2.DescribeTargetHealthOutput{ + TargetHealthDescriptions: descriptions, + }, nil +} + +// --- Listener operations --- + +func (s *ELBv2ServiceImpl) CreateListener(input *elbv2.CreateListenerInput, accountID string) (*elbv2.CreateListenerOutput, error) { + if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if len(input.DefaultActions) == 0 { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if lb == nil { + return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound) + } + + protocol := ProtocolHTTP + if input.Protocol != nil && *input.Protocol != "" { + protocol = *input.Protocol + } + + port := int64(80) + if input.Port != nil { + port = *input.Port + } + + // Check for duplicate listener on same port + existingListeners, err := s.store.ListListenersByLB(lb.LoadBalancerArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + for _, l := range existingListeners { + if l.Port == port { + return nil, errors.New(awserrors.ErrorELBv2DuplicateListener) + } + } + + listenerID := utils.GenerateResourceID("lst") + listenerArn := buildListenerArn(s.region, accountID, lb.Name, lb.LoadBalancerID, listenerID) + + var actions []ListenerAction + for _, a := range input.DefaultActions { + action := ListenerAction{} + if a.Type != nil { + action.Type = *a.Type + } + if a.TargetGroupArn != nil { + action.TargetGroupArn = *a.TargetGroupArn + } + actions = append(actions, action) + } + + record := &ListenerRecord{ + ListenerArn: listenerArn, + ListenerID: listenerID, + LoadBalancerArn: lb.LoadBalancerArn, + Protocol: protocol, + Port: port, + DefaultActions: actions, + AccountID: accountID, + CreatedAt: time.Now().UTC(), + } + + if err := s.store.PutListener(record); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + // Start or reload HAProxy now that a listener exists + s.pushConfig(lb) + + slog.Info("CreateListener completed", "listenerArn", listenerArn, "lbArn", lb.LoadBalancerArn, "port", port, "accountID", accountID) + + return &elbv2.CreateListenerOutput{ + Listeners: []*elbv2.Listener{s.listenerRecordToSDK(record)}, + }, nil +} + +func (s *ELBv2ServiceImpl) DeleteListener(input *elbv2.DeleteListenerInput, accountID string) (*elbv2.DeleteListenerOutput, error) { + if input.ListenerArn == nil || *input.ListenerArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + listener, err := s.store.GetListenerByArn(*input.ListenerArn) + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + if listener == nil { + return nil, errors.New(awserrors.ErrorELBv2ListenerNotFound) + } + + if err := s.store.DeleteListener(listener.ListenerID); err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + // Reload or stop HAProxy after listener removal + lb, lbErr := s.store.GetLoadBalancerByArn(listener.LoadBalancerArn) + if lbErr == nil && lb != nil { + s.pushConfig(lb) + } + + slog.Info("DeleteListener completed", "listenerArn", *input.ListenerArn, "accountID", accountID) + + return &elbv2.DeleteListenerOutput{}, nil +} + +func (s *ELBv2ServiceImpl) DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error) { + var listeners []*ListenerRecord + var err error + + if input.LoadBalancerArn != nil && *input.LoadBalancerArn != "" { + listeners, err = s.store.ListListenersByLB(*input.LoadBalancerArn) + } else { + listeners, err = s.store.ListListeners() + } + if err != nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + // Filter by ARN if specified + arnFilter := make(map[string]bool) + for _, arn := range input.ListenerArns { + if arn != nil { + arnFilter[*arn] = true + } + } + + result := make([]*elbv2.Listener, 0) + for _, l := range listeners { + if l.AccountID != accountID { + continue + } + if len(arnFilter) > 0 && !arnFilter[l.ListenerArn] { + continue + } + result = append(result, s.listenerRecordToSDK(l)) + } + + return &elbv2.DescribeListenersOutput{ + Listeners: result, + }, nil +} + +// --- SDK type conversion helpers --- + +func (s *ELBv2ServiceImpl) lbRecordToSDK(r *LoadBalancerRecord) *elbv2.LoadBalancer { + lb := &elbv2.LoadBalancer{ + LoadBalancerArn: aws.String(r.LoadBalancerArn), + LoadBalancerName: aws.String(r.Name), + DNSName: aws.String(r.DNSName), + Scheme: aws.String(r.Scheme), + Type: aws.String(r.Type), + IpAddressType: aws.String(r.IPAddressType), + CreatedTime: aws.Time(r.CreatedAt), + VpcId: aws.String(r.VpcId), + State: &elbv2.LoadBalancerState{ + Code: aws.String(r.State), + }, + } + + for _, sg := range r.SecurityGroups { + lb.SecurityGroups = append(lb.SecurityGroups, aws.String(sg)) + } + + for _, az := range r.AvailZones { + lb.AvailabilityZones = append(lb.AvailabilityZones, &elbv2.AvailabilityZone{ + ZoneName: aws.String(az.ZoneName), + SubnetId: aws.String(az.SubnetId), + }) + } + + return lb +} + +func (s *ELBv2ServiceImpl) tgRecordToSDK(r *TargetGroupRecord) *elbv2.TargetGroup { + return &elbv2.TargetGroup{ + TargetGroupArn: aws.String(r.TargetGroupArn), + TargetGroupName: aws.String(r.Name), + Protocol: aws.String(r.Protocol), + Port: aws.Int64(r.Port), + VpcId: aws.String(r.VpcId), + TargetType: aws.String(r.TargetType), + HealthCheckProtocol: aws.String(r.HealthCheck.Protocol), + HealthCheckPort: aws.String(r.HealthCheck.Port), + HealthCheckPath: aws.String(r.HealthCheck.Path), + HealthCheckIntervalSeconds: aws.Int64(r.HealthCheck.IntervalSeconds), + HealthCheckTimeoutSeconds: aws.Int64(r.HealthCheck.TimeoutSeconds), + HealthyThresholdCount: aws.Int64(r.HealthCheck.HealthyThreshold), + UnhealthyThresholdCount: aws.Int64(r.HealthCheck.UnhealthyThreshold), + Matcher: &elbv2.Matcher{ + HttpCode: aws.String(r.HealthCheck.Matcher), + }, + } +} + +func (s *ELBv2ServiceImpl) listenerRecordToSDK(r *ListenerRecord) *elbv2.Listener { + listener := &elbv2.Listener{ + ListenerArn: aws.String(r.ListenerArn), + LoadBalancerArn: aws.String(r.LoadBalancerArn), + Protocol: aws.String(r.Protocol), + Port: aws.Int64(r.Port), + } + + for _, a := range r.DefaultActions { + action := &elbv2.Action{ + Type: aws.String(a.Type), + TargetGroupArn: aws.String(a.TargetGroupArn), + } + listener.DefaultActions = append(listener.DefaultActions, action) + } + + return listener +} diff --git a/spinifex/handlers/elbv2/service_impl_health_test.go b/spinifex/handlers/elbv2/service_impl_health_test.go new file mode 100644 index 000000000..a7fe85433 --- /dev/null +++ b/spinifex/handlers/elbv2/service_impl_health_test.go @@ -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) +} diff --git a/spinifex/handlers/elbv2/service_impl_test.go b/spinifex/handlers/elbv2/service_impl_test.go new file mode 100644 index 000000000..b275d8232 --- /dev/null +++ b/spinifex/handlers/elbv2/service_impl_test.go @@ -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 +} + diff --git a/spinifex/handlers/elbv2/service_impl_vpc_test.go b/spinifex/handlers/elbv2/service_impl_vpc_test.go new file mode 100644 index 000000000..d954c5c0b --- /dev/null +++ b/spinifex/handlers/elbv2/service_impl_vpc_test.go @@ -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") + } + } +} diff --git a/spinifex/handlers/elbv2/service_nats.go b/spinifex/handlers/elbv2/service_nats.go new file mode 100644 index 000000000..d14dc782d --- /dev/null +++ b/spinifex/handlers/elbv2/service_nats.go @@ -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) +} diff --git a/spinifex/handlers/elbv2/service_nats_e2e_test.go b/spinifex/handlers/elbv2/service_nats_e2e_test.go new file mode 100644 index 000000000..0665e1da6 --- /dev/null +++ b/spinifex/handlers/elbv2/service_nats_e2e_test.go @@ -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) +} diff --git a/spinifex/handlers/elbv2/service_nats_test.go b/spinifex/handlers/elbv2/service_nats_test.go new file mode 100644 index 000000000..e304488a2 --- /dev/null +++ b/spinifex/handlers/elbv2/service_nats_test.go @@ -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) +} diff --git a/spinifex/handlers/elbv2/store.go b/spinifex/handlers/elbv2/store.go new file mode 100644 index 000000000..81ea5dd15 --- /dev/null +++ b/spinifex/handlers/elbv2/store.go @@ -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 +} diff --git a/spinifex/handlers/elbv2/store_test.go b/spinifex/handlers/elbv2/store_test.go new file mode 100644 index 000000000..e4ae55e8e --- /dev/null +++ b/spinifex/handlers/elbv2/store_test.go @@ -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) +} diff --git a/spinifex/handlers/elbv2/types.go b/spinifex/handlers/elbv2/types.go new file mode 100644 index 000000000..674cfc607 --- /dev/null +++ b/spinifex/handlers/elbv2/types.go @@ -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"` +} diff --git a/spinifex/handlers/elbv2/types_test.go b/spinifex/handlers/elbv2/types_test.go new file mode 100644 index 000000000..2c66b8372 --- /dev/null +++ b/spinifex/handlers/elbv2/types_test.go @@ -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") +} diff --git a/spinifex/vm/vm.go b/spinifex/vm/vm.go index ba5a9a61b..4c3b1fb86 100644 --- a/spinifex/vm/vm.go +++ b/spinifex/vm/vm.go @@ -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 diff --git a/tests/e2e/TEST_COVERAGE.md b/tests/e2e/TEST_COVERAGE.md index 5ef052e22..fdc6df266 100644 --- a/tests/e2e/TEST_COVERAGE.md +++ b/tests/e2e/TEST_COVERAGE.md @@ -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": ""}` 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 diff --git a/tests/e2e/run-elbv2-dataplane-e2e.sh b/tests/e2e/run-elbv2-dataplane-e2e.sh new file mode 100755 index 000000000..ddf7967d4 --- /dev/null +++ b/tests/e2e/run-elbv2-dataplane-e2e.sh @@ -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 "" diff --git a/tests/e2e/run-elbv2-e2e.sh b/tests/e2e/run-elbv2-e2e.sh new file mode 100755 index 000000000..2cea4324e --- /dev/null +++ b/tests/e2e/run-elbv2-e2e.sh @@ -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 ""