mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: ALB/ELBv2 service
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# build-alb-image.sh — Build a minimal Alpine AMI for ALB VMs
|
||||
#
|
||||
# Creates a pre-baked Alpine Linux image with HAProxy + alb-agent installed,
|
||||
# ready for import as a Spinifex AMI. ALB VMs boot in ~3s and need no internet
|
||||
# access or package installation at runtime.
|
||||
#
|
||||
# Requirements: qemu-nbd, sudo (for mount/chroot), curl
|
||||
# Usage: ./scripts/build-alb-image.sh [--import]
|
||||
# --import Also import the image as an AMI via spx admin images import
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BUILD_DIR="/tmp/alb-image-build"
|
||||
ALPINE_VERSION="3.21.6"
|
||||
ALPINE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2"
|
||||
ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/cloud/${ALPINE_IMAGE}"
|
||||
OUTPUT_IMAGE="${BUILD_DIR}/alb-alpine.qcow2"
|
||||
OUTPUT_RAW="${BUILD_DIR}/alb-alpine.raw"
|
||||
NBD_DEV="/dev/nbd0"
|
||||
MOUNT_DIR="${BUILD_DIR}/mnt"
|
||||
ALB_AGENT_BIN="${PROJECT_DIR}/bin/alb-agent"
|
||||
|
||||
DO_IMPORT=false
|
||||
if [[ "${1:-}" == "--import" ]]; then
|
||||
DO_IMPORT=true
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
echo "Cleaning up..."
|
||||
sudo umount "${MOUNT_DIR}" 2>/dev/null || true
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
|
||||
echo "Done."
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== ALB Alpine Image Builder ==="
|
||||
echo "Alpine version: ${ALPINE_VERSION}"
|
||||
echo "Build dir: ${BUILD_DIR}"
|
||||
echo ""
|
||||
|
||||
# Step 0: Check prerequisites
|
||||
if [ ! -f "$ALB_AGENT_BIN" ]; then
|
||||
echo "Building alb-agent (static)..."
|
||||
(cd "$PROJECT_DIR" && CGO_ENABLED=0 go build -o bin/alb-agent cmd/alb-agent/main.go)
|
||||
else
|
||||
# Verify the existing binary is statically linked
|
||||
if ! file "$ALB_AGENT_BIN" | grep -q "statically linked"; then
|
||||
echo "Rebuilding alb-agent as static binary (Alpine uses musl)..."
|
||||
(cd "$PROJECT_DIR" && CGO_ENABLED=0 go build -o bin/alb-agent cmd/alb-agent/main.go)
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v qemu-nbd &>/dev/null; then
|
||||
echo "ERROR: qemu-nbd not found. Install qemu-utils."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_DIR" "$MOUNT_DIR"
|
||||
|
||||
# Step 1: Download Alpine cloud image
|
||||
if [ -f "${BUILD_DIR}/${ALPINE_IMAGE}" ]; then
|
||||
echo "Alpine image already downloaded."
|
||||
else
|
||||
echo "Downloading Alpine ${ALPINE_VERSION} cloud image..."
|
||||
curl -L -o "${BUILD_DIR}/${ALPINE_IMAGE}" "$ALPINE_URL"
|
||||
fi
|
||||
|
||||
# Step 2: Copy image for customization
|
||||
echo "Copying image for customization..."
|
||||
cp "${BUILD_DIR}/${ALPINE_IMAGE}" "$OUTPUT_IMAGE"
|
||||
|
||||
# Resize the image to 512M (Alpine cloud images are ~200MB, need room for packages)
|
||||
qemu-img resize "$OUTPUT_IMAGE" 512M
|
||||
|
||||
# Step 3: Connect via qemu-nbd
|
||||
echo "Connecting image via qemu-nbd..."
|
||||
sudo modprobe nbd max_part=4 2>/dev/null || true
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
|
||||
sudo qemu-nbd --connect="${NBD_DEV}" "$OUTPUT_IMAGE"
|
||||
sleep 1
|
||||
|
||||
# Alpine cloud images have ext4 directly on the block device (no partition table).
|
||||
# Resize the filesystem to fill the resized image.
|
||||
echo "Resizing filesystem..."
|
||||
sudo e2fsck -f -y "${NBD_DEV}" 2>/dev/null || true
|
||||
sudo resize2fs "${NBD_DEV}" 2>/dev/null || true
|
||||
|
||||
# Step 4: Mount and customize
|
||||
echo "Mounting root filesystem..."
|
||||
sudo mount "${NBD_DEV}" "$MOUNT_DIR"
|
||||
|
||||
# Set up resolv.conf for DNS inside chroot
|
||||
sudo cp /etc/resolv.conf "${MOUNT_DIR}/etc/resolv.conf"
|
||||
|
||||
echo "Installing packages in chroot..."
|
||||
sudo chroot "$MOUNT_DIR" /bin/sh -c '
|
||||
set -e
|
||||
# Enable community repo (haproxy is in community)
|
||||
sed -i "s|^#\(.*community\)|\1|" /etc/apk/repositories 2>/dev/null || true
|
||||
|
||||
# Ensure community repo is present
|
||||
if ! grep -q "community" /etc/apk/repositories; then
|
||||
MIRROR=$(grep "main" /etc/apk/repositories | head -1 | sed "s|/main|/community|")
|
||||
echo "$MIRROR" >> /etc/apk/repositories
|
||||
fi
|
||||
|
||||
apk update
|
||||
apk add haproxy curl
|
||||
|
||||
# Enable haproxy to start at boot (but do not start it now)
|
||||
rc-update add haproxy default 2>/dev/null || true
|
||||
|
||||
# Create haproxy config directory
|
||||
mkdir -p /etc/haproxy
|
||||
cat > /etc/haproxy/haproxy.cfg <<EOF
|
||||
# Placeholder config — replaced by alb-agent on first config push
|
||||
global
|
||||
daemon
|
||||
maxconn 256
|
||||
|
||||
defaults
|
||||
mode http
|
||||
timeout connect 5s
|
||||
timeout client 30s
|
||||
timeout server 30s
|
||||
|
||||
frontend health
|
||||
bind *:8405
|
||||
http-request return status 200 content-type text/plain string "ok"
|
||||
EOF
|
||||
|
||||
# Create alb-agent service directory
|
||||
mkdir -p /etc/init.d
|
||||
'
|
||||
|
||||
# Step 5: Copy alb-agent binary into the image
|
||||
echo "Installing alb-agent binary..."
|
||||
sudo cp "$ALB_AGENT_BIN" "${MOUNT_DIR}/usr/local/bin/alb-agent"
|
||||
sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/alb-agent"
|
||||
|
||||
# Create OpenRC init script for alb-agent
|
||||
sudo tee "${MOUNT_DIR}/etc/init.d/alb-agent" > /dev/null <<'INITSCRIPT'
|
||||
#!/sbin/openrc-run
|
||||
|
||||
description="ALB NATS Config Agent"
|
||||
command="/usr/local/bin/alb-agent"
|
||||
command_args="--lb-id=${ALB_LB_ID:-unknown} --nats=${ALB_NATS_URL:-nats://10.0.2.2:4222}"
|
||||
command_background=true
|
||||
pidfile="/run/alb-agent.pid"
|
||||
output_log="/var/log/alb-agent.log"
|
||||
error_log="/var/log/alb-agent.log"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after firewall
|
||||
}
|
||||
INITSCRIPT
|
||||
sudo chmod 755 "${MOUNT_DIR}/etc/init.d/alb-agent"
|
||||
|
||||
# Create a cloud-init module-final script hook that starts alb-agent with the
|
||||
# correct LB ID and NATS URL (passed via cloud-init user-data env vars).
|
||||
# Cloud-init on Alpine runs runcmd which we'll use to start the agent.
|
||||
|
||||
# Step 6: Clean up and unmount
|
||||
echo "Cleaning up image..."
|
||||
sudo chroot "$MOUNT_DIR" /bin/sh -c '
|
||||
apk cache clean 2>/dev/null || true
|
||||
rm -rf /var/cache/apk/* /tmp/*
|
||||
'
|
||||
|
||||
# Restore original resolv.conf (cloud-init will set it on boot)
|
||||
sudo rm -f "${MOUNT_DIR}/etc/resolv.conf"
|
||||
|
||||
echo "Unmounting..."
|
||||
sudo umount "$MOUNT_DIR"
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}"
|
||||
|
||||
# Step 7: Convert to raw for import
|
||||
echo "Converting to raw format..."
|
||||
qemu-img convert -f qcow2 -O raw "$OUTPUT_IMAGE" "$OUTPUT_RAW"
|
||||
|
||||
RAW_SIZE=$(stat -c%s "$OUTPUT_RAW")
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
echo " qcow2: $OUTPUT_IMAGE ($(du -h "$OUTPUT_IMAGE" | cut -f1))"
|
||||
echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))"
|
||||
echo ""
|
||||
|
||||
if [ "$DO_IMPORT" = true ]; then
|
||||
echo "Importing as AMI..."
|
||||
(cd "$PROJECT_DIR" && ./bin/spx admin images import \
|
||||
--file "$OUTPUT_RAW" \
|
||||
--distro alpine \
|
||||
--version "${ALPINE_VERSION}-alb" \
|
||||
--arch x86_64 \
|
||||
--config "$HOME/spinifex/config/spinifex.toml")
|
||||
else
|
||||
echo "To import as AMI, run:"
|
||||
echo " cd $PROJECT_DIR && ./bin/spx admin images import \\"
|
||||
echo " --file $OUTPUT_RAW \\"
|
||||
echo " --distro alpine --version ${ALPINE_VERSION}-alb --arch x86_64 \\"
|
||||
echo " --config \$HOME/spinifex/config/spinifex.toml"
|
||||
fi
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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."},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleELBv2CreateLoadBalancer(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateLoadBalancer)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteLoadBalancer(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteLoadBalancer)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeLoadBalancers(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancers)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2CreateTargetGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateTargetGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteTargetGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteTargetGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTargetGroups(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroups)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2RegisterTargets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.RegisterTargets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeregisterTargets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeregisterTargets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTargetHealth(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTargetHealth)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2CreateListener(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateListener)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteListener(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteListener)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeListeners(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeListeners)
|
||||
}
|
||||
@@ -182,6 +182,12 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
eni := eniOut.NetworkInterface
|
||||
instance.ENIId = *eni.NetworkInterfaceId
|
||||
instance.ENIMac = *eni.MacAddress
|
||||
|
||||
// Mark ENI as attached to this instance so attachment.instance-id
|
||||
// filter works (used by ELBv2 RegisterTargets to resolve target IPs).
|
||||
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
|
||||
slog.Warn("Failed to attach ENI to instance record", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
|
||||
}
|
||||
ec2Instance.SetPrivateIpAddress(*eni.PrivateIpAddress)
|
||||
ec2Instance.SetSubnetId(*runInstancesInput.SubnetId)
|
||||
ec2Instance.SetVpcId(*eni.VpcId)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
)
|
||||
|
||||
// Compile-time check that Daemon implements SystemInstanceLauncher.
|
||||
var _ handlers_elbv2.SystemInstanceLauncher = (*Daemon)(nil)
|
||||
|
||||
// LaunchSystemInstance creates and starts a system-managed VM. The VM is owned
|
||||
// by the system account (GlobalAccountID) and is not visible to customer
|
||||
// DescribeInstances calls.
|
||||
//
|
||||
// This is the internal equivalent of RunInstances — it reuses the same
|
||||
// instance service, resource manager, volume preparation, and QEMU launch
|
||||
// path, but skips the NATS request/response envelope and key pair validation.
|
||||
func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput) (*handlers_elbv2.SystemInstanceOutput, error) {
|
||||
accountID := utils.GlobalAccountID
|
||||
|
||||
// Validate instance type
|
||||
instanceType, exists := d.resourceMgr.instanceTypes[input.InstanceType]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unknown instance type: %s", input.InstanceType)
|
||||
}
|
||||
|
||||
// Validate AMI
|
||||
if d.imageService == nil {
|
||||
return nil, fmt.Errorf("image service not initialized")
|
||||
}
|
||||
if _, err := d.imageService.GetAMIConfig(input.ImageID); err != nil {
|
||||
return nil, fmt.Errorf("AMI %s not found: %w", input.ImageID, err)
|
||||
}
|
||||
|
||||
// Allocate resources
|
||||
if err := d.resourceMgr.allocate(instanceType); err != nil {
|
||||
return nil, fmt.Errorf("insufficient capacity for %s: %w", input.InstanceType, err)
|
||||
}
|
||||
|
||||
// Build a RunInstancesInput for the instance service
|
||||
runInput := &ec2.RunInstancesInput{
|
||||
InstanceType: aws.String(input.InstanceType),
|
||||
ImageId: aws.String(input.ImageID),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
if input.SubnetID != "" {
|
||||
runInput.SubnetId = aws.String(input.SubnetID)
|
||||
}
|
||||
if input.UserData != "" {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(input.UserData))
|
||||
runInput.UserData = aws.String(encoded)
|
||||
}
|
||||
|
||||
// Create VM via instance service
|
||||
instance, ec2Instance, err := d.instanceService.RunInstance(runInput)
|
||||
if err != nil {
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
return nil, fmt.Errorf("create instance: %w", err)
|
||||
}
|
||||
|
||||
instance.AccountID = accountID
|
||||
instance.Reservation = &ec2.Reservation{}
|
||||
instance.Reservation.SetReservationId(utils.GenerateResourceID("r"))
|
||||
instance.Reservation.SetOwnerId(accountID)
|
||||
instance.Reservation.Instances = []*ec2.Instance{ec2Instance}
|
||||
|
||||
// Attach ENI — either use pre-created one or auto-create
|
||||
privateIP := ""
|
||||
if input.ENIID != "" {
|
||||
// Use pre-created ENI (e.g. ALB ENI)
|
||||
instance.ENIId = input.ENIID
|
||||
instance.ENIMac = input.ENIMac
|
||||
privateIP = input.ENIIP
|
||||
ec2Instance.SetPrivateIpAddress(privateIP)
|
||||
if input.SubnetID != "" {
|
||||
ec2Instance.SetSubnetId(input.SubnetID)
|
||||
}
|
||||
// Mark ENI as attached to this instance
|
||||
if d.vpcService != nil {
|
||||
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to attach ENI", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
|
||||
}
|
||||
}
|
||||
} else if input.SubnetID != "" && d.vpcService != nil {
|
||||
// Auto-create ENI in subnet
|
||||
eniOut, eniErr := d.vpcService.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(input.SubnetID),
|
||||
Description: aws.String("System interface for " + instance.ID),
|
||||
}, accountID)
|
||||
if eniErr != nil {
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
return nil, fmt.Errorf("create ENI: %w", eniErr)
|
||||
}
|
||||
eni := eniOut.NetworkInterface
|
||||
instance.ENIId = *eni.NetworkInterfaceId
|
||||
instance.ENIMac = *eni.MacAddress
|
||||
privateIP = *eni.PrivateIpAddress
|
||||
ec2Instance.SetPrivateIpAddress(privateIP)
|
||||
ec2Instance.SetSubnetId(input.SubnetID)
|
||||
ec2Instance.SetVpcId(*eni.VpcId)
|
||||
// Mark ENI as attached
|
||||
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to attach auto-created ENI", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add to daemon state so LaunchInstance can find it
|
||||
d.Instances.Mu.Lock()
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if err := d.WriteState(); err != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to write state", "instanceId", instance.ID, "err", err)
|
||||
}
|
||||
|
||||
// Subscribe to per-instance NATS topic for terminate commands
|
||||
d.mu.Lock()
|
||||
sub, subErr := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
|
||||
if subErr != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to subscribe to instance topic", "instanceId", instance.ID, "err", subErr)
|
||||
} else {
|
||||
d.natsSubscriptions[instance.ID] = sub
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Pre-compute dev MAC for dual-NIC cloud-init
|
||||
if d.config.Daemon.DevNetworking && instance.ENIId != "" {
|
||||
instance.DevMAC = generateDevMAC(instance.ID)
|
||||
}
|
||||
|
||||
// Store extra hostfwd ports (filled in by StartInstance with actual host ports)
|
||||
if len(input.HostfwdPorts) > 0 {
|
||||
instance.ExtraHostfwd = make(map[int]int, len(input.HostfwdPorts))
|
||||
for _, port := range input.HostfwdPorts {
|
||||
instance.ExtraHostfwd[port] = 0 // host port assigned in StartInstance
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare volumes (root volume clone from AMI, cloud-init ISO, etc.)
|
||||
volumeInfos, err := d.instanceService.GenerateVolumes(runInput, instance)
|
||||
if err != nil {
|
||||
d.cleanupFailedSystemInstance(instance, instanceType)
|
||||
return nil, fmt.Errorf("generate volumes: %w", err)
|
||||
}
|
||||
|
||||
instance.Instance.BlockDeviceMappings = make([]*ec2.InstanceBlockDeviceMapping, 0, len(volumeInfos))
|
||||
for _, vi := range volumeInfos {
|
||||
mapping := &ec2.InstanceBlockDeviceMapping{}
|
||||
mapping.SetDeviceName(vi.DeviceName)
|
||||
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
|
||||
mapping.Ebs.SetVolumeId(vi.VolumeId)
|
||||
mapping.Ebs.SetAttachTime(vi.AttachTime)
|
||||
mapping.Ebs.SetDeleteOnTermination(vi.DeleteOnTermination)
|
||||
mapping.Ebs.SetStatus("attached")
|
||||
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
|
||||
}
|
||||
|
||||
// Launch QEMU VM
|
||||
if err := d.LaunchInstance(instance); err != nil {
|
||||
d.cleanupFailedSystemInstance(instance, instanceType)
|
||||
return nil, fmt.Errorf("launch instance: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("LaunchSystemInstance completed",
|
||||
"instanceId", instance.ID,
|
||||
"instanceType", input.InstanceType,
|
||||
"privateIp", privateIP,
|
||||
)
|
||||
|
||||
return &handlers_elbv2.SystemInstanceOutput{
|
||||
InstanceID: instance.ID,
|
||||
PrivateIP: privateIP,
|
||||
HostfwdMap: instance.ExtraHostfwd,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TerminateSystemInstance stops and cleans up a system-managed VM.
|
||||
func (d *Daemon) TerminateSystemInstance(instanceID string) error {
|
||||
d.Instances.Mu.Lock()
|
||||
instance, exists := d.Instances.VMS[instanceID]
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s not found", instanceID)
|
||||
}
|
||||
|
||||
// Transition to shutting-down
|
||||
if err := d.TransitionState(instance, vm.StateShuttingDown); err != nil {
|
||||
return fmt.Errorf("transition to shutting-down: %w", err)
|
||||
}
|
||||
|
||||
// Stop the VM (QEMU shutdown, volume unmount, tap cleanup)
|
||||
if err := d.stopInstance(map[string]*vm.VM{instanceID: instance}, true); err != nil {
|
||||
slog.Error("TerminateSystemInstance: stopInstance failed", "instanceId", instanceID, "err", err)
|
||||
return fmt.Errorf("stop instance: %w", err)
|
||||
}
|
||||
|
||||
if err := d.TransitionState(instance, vm.StateTerminated); err != nil {
|
||||
slog.Warn("TerminateSystemInstance: failed to transition to terminated", "instanceId", instanceID, "err", err)
|
||||
}
|
||||
|
||||
// Write to terminated KV bucket (auto-expires)
|
||||
if d.jsManager != nil {
|
||||
if err := d.jsManager.WriteTerminatedInstance(instanceID, instance); err != nil {
|
||||
slog.Warn("TerminateSystemInstance: failed to write terminated state", "instanceId", instanceID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up local state
|
||||
d.Instances.Mu.Lock()
|
||||
delete(d.Instances.VMS, instanceID)
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
// Unsubscribe from per-instance NATS topic
|
||||
d.mu.Lock()
|
||||
if sub, ok := d.natsSubscriptions[instanceID]; ok {
|
||||
if err := sub.Unsubscribe(); err != nil {
|
||||
slog.Warn("TerminateSystemInstance: failed to unsubscribe", "instanceId", instanceID, "err", err)
|
||||
}
|
||||
delete(d.natsSubscriptions, instanceID)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Deallocate resources
|
||||
if it, ok := d.resourceMgr.instanceTypes[instance.InstanceType]; ok {
|
||||
d.resourceMgr.deallocate(it)
|
||||
}
|
||||
|
||||
slog.Info("TerminateSystemInstance completed", "instanceId", instanceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupFailedSystemInstance handles cleanup when a system instance launch fails
|
||||
// after partial setup (state added, volumes created, etc).
|
||||
func (d *Daemon) cleanupFailedSystemInstance(instance *vm.VM, instanceType *ec2.InstanceTypeInfo) {
|
||||
d.markInstanceFailed(instance, "system_instance_launch_failed")
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
|
||||
// Clean up ENI if we auto-created one (not pre-created)
|
||||
// Pre-created ENIs are managed by the caller (e.g. ELBv2 service)
|
||||
if instance.ENIId != "" && d.vpcService != nil {
|
||||
// Only clean up ENIs we created (description starts with "System interface")
|
||||
result, err := d.vpcService.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
NetworkInterfaceIds: []*string{aws.String(instance.ENIId)},
|
||||
}, utils.GlobalAccountID)
|
||||
if err == nil && len(result.NetworkInterfaces) > 0 {
|
||||
desc := aws.StringValue(result.NetworkInterfaces[0].Description)
|
||||
if len(desc) >= 16 && desc[:16] == "System interface" {
|
||||
if _, delErr := d.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
|
||||
NetworkInterfaceId: aws.String(instance.ENIId),
|
||||
}, utils.GlobalAccountID); delErr != nil {
|
||||
slog.Warn("Failed to cleanup system ENI", "eniId", instance.ENIId, "err", delErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSystemInstance polls until the instance reaches running state or times out.
|
||||
func (d *Daemon) WaitForSystemInstance(instanceID string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
d.Instances.Mu.Lock()
|
||||
inst, exists := d.Instances.VMS[instanceID]
|
||||
var status vm.InstanceState
|
||||
if exists {
|
||||
status = inst.Status
|
||||
}
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s disappeared", instanceID)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case vm.StateRunning:
|
||||
return nil
|
||||
case vm.StateError, vm.StateShuttingDown, vm.StateTerminated:
|
||||
return fmt.Errorf("instance %s in terminal state: %s", instanceID, status)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("instance %s did not reach running state within %s", instanceID, timeout)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
)
|
||||
|
||||
func TestWaitForSystemInstance_AlreadyRunning(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-test1": {ID: "i-test1", Status: vm.StateRunning},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-test1", 1*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for running instance, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_NotFound(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-nonexistent", 500*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_ErrorState(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-failed": {ID: "i-failed", Status: vm.StateError},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-failed", 1*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_TransitionsToRunning(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-pending", Status: vm.StateProvisioning}
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-pending": inst,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Transition to running after a short delay
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
d.Instances.Mu.Lock()
|
||||
inst.Status = vm.StateRunning
|
||||
d.Instances.Mu.Unlock()
|
||||
}()
|
||||
|
||||
err := d.WaitForSystemInstance("i-pending", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected instance to reach running, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_Timeout(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-stuck": {ID: "i-stuck", Status: vm.StateProvisioning},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-stuck", 600*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awsec2query"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
gateway_elbv2 "github.com/mulgadc/spinifex/spinifex/gateway/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
)
|
||||
|
||||
// ELBv2Handler processes parsed query args and returns XML response bytes.
|
||||
type ELBv2Handler func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error)
|
||||
|
||||
// elbv2Handler creates a type-safe ELBv2Handler that allocates the typed input struct,
|
||||
// parses query params into it, calls the handler, and marshals the output to XML.
|
||||
// ELBv2 uses the IAM-style XML envelope: <ActionResponse><ActionResult>...</ActionResult></ActionResponse>
|
||||
func elbv2Handler[In any](handler func(*In, *GatewayConfig, string) (any, error)) ELBv2Handler {
|
||||
return func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) {
|
||||
input := new(In)
|
||||
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := handler(input, gw, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := utils.GenerateIAMXMLPayload(action, output)
|
||||
xmlOutput, err := utils.MarshalToXML(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to marshal response to XML")
|
||||
}
|
||||
return xmlOutput, nil
|
||||
}
|
||||
}
|
||||
|
||||
var elbv2Actions = map[string]ELBv2Handler{
|
||||
"CreateLoadBalancer": elbv2Handler(func(input *elbv2.CreateLoadBalancerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.CreateLoadBalancer(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteLoadBalancer": elbv2Handler(func(input *elbv2.DeleteLoadBalancerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeleteLoadBalancer(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeLoadBalancers": elbv2Handler(func(input *elbv2.DescribeLoadBalancersInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeLoadBalancers(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateTargetGroup": elbv2Handler(func(input *elbv2.CreateTargetGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.CreateTargetGroup(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteTargetGroup": elbv2Handler(func(input *elbv2.DeleteTargetGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeleteTargetGroup(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeTargetGroups": elbv2Handler(func(input *elbv2.DescribeTargetGroupsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeTargetGroups(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RegisterTargets": elbv2Handler(func(input *elbv2.RegisterTargetsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.RegisterTargets(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeregisterTargets": elbv2Handler(func(input *elbv2.DeregisterTargetsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeregisterTargets(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeTargetHealth": elbv2Handler(func(input *elbv2.DescribeTargetHealthInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeTargetHealth(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateListener": elbv2Handler(func(input *elbv2.CreateListenerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.CreateListener(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteListener": elbv2Handler(func(input *elbv2.DeleteListenerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeleteListener(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeListeners": elbv2Handler(func(input *elbv2.DescribeListenersInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeListeners(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) ELBv2_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read ELBv2 request body", "error", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
queryArgs := ParseAWSQueryArgs(string(body))
|
||||
|
||||
action := queryArgs["Action"]
|
||||
handler, ok := elbv2Actions[action]
|
||||
if !ok {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
if err := gw.checkPolicy(r, "elasticloadbalancing", action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gw.NATSConn == nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
accountID, _ := r.Context().Value(ctxAccountID).(string)
|
||||
if accountID == "" {
|
||||
slog.Error("ELBv2_Request: no account ID in auth context")
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
xmlOutput, err := handler(action, queryArgs, gw, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.Error("Failed to write ELBv2 response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// CreateListener handles the ELBv2 CreateListener API call.
|
||||
func CreateListener(input *elbv2.CreateListenerInput, natsConn *nats.Conn, accountID string) (elbv2.CreateListenerOutput, error) {
|
||||
var output elbv2.CreateListenerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if len(input.DefaultActions) == 0 {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.CreateListener(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// CreateLoadBalancer handles the ELBv2 CreateLoadBalancer API call.
|
||||
func CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, natsConn *nats.Conn, accountID string) (elbv2.CreateLoadBalancerOutput, error) {
|
||||
var output elbv2.CreateLoadBalancerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.Name == nil || *input.Name == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.CreateLoadBalancer(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// CreateTargetGroup handles the ELBv2 CreateTargetGroup API call.
|
||||
func CreateTargetGroup(input *elbv2.CreateTargetGroupInput, natsConn *nats.Conn, accountID string) (elbv2.CreateTargetGroupOutput, error) {
|
||||
var output elbv2.CreateTargetGroupOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.Name == nil || *input.Name == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.CreateTargetGroup(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeleteListener handles the ELBv2 DeleteListener API call.
|
||||
func DeleteListener(input *elbv2.DeleteListenerInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteListenerOutput, error) {
|
||||
var output elbv2.DeleteListenerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.ListenerArn == nil || *input.ListenerArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeleteListener(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeleteLoadBalancer handles the ELBv2 DeleteLoadBalancer API call.
|
||||
func DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteLoadBalancerOutput, error) {
|
||||
var output elbv2.DeleteLoadBalancerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeleteLoadBalancer(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeleteTargetGroup handles the ELBv2 DeleteTargetGroup API call.
|
||||
func DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteTargetGroupOutput, error) {
|
||||
var output elbv2.DeleteTargetGroupOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeleteTargetGroup(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeregisterTargets handles the ELBv2 DeregisterTargets API call.
|
||||
func DeregisterTargets(input *elbv2.DeregisterTargetsInput, natsConn *nats.Conn, accountID string) (elbv2.DeregisterTargetsOutput, error) {
|
||||
var output elbv2.DeregisterTargetsOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if len(input.Targets) == 0 {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeregisterTargets(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DescribeListeners handles the ELBv2 DescribeListeners API call.
|
||||
func DescribeListeners(input *elbv2.DescribeListenersInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeListenersOutput, error) {
|
||||
var output elbv2.DescribeListenersOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DescribeListeners(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DescribeLoadBalancers handles the ELBv2 DescribeLoadBalancers API call.
|
||||
func DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeLoadBalancersOutput, error) {
|
||||
var output elbv2.DescribeLoadBalancersOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DescribeLoadBalancers(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DescribeTargetGroups handles the ELBv2 DescribeTargetGroups API call.
|
||||
func DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeTargetGroupsOutput, error) {
|
||||
var output elbv2.DescribeTargetGroupsOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DescribeTargetGroups(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DescribeTargetHealth handles the ELBv2 DescribeTargetHealth API call.
|
||||
func DescribeTargetHealth(input *elbv2.DescribeTargetHealthInput, natsConn *nats.Conn, accountID string) (elbv2.DescribeTargetHealthOutput, error) {
|
||||
var output elbv2.DescribeTargetHealthOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DescribeTargetHealth(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// RegisterTargets handles the ELBv2 RegisterTargets API call.
|
||||
func RegisterTargets(input *elbv2.RegisterTargetsInput, natsConn *nats.Conn, accountID string) (elbv2.RegisterTargetsOutput, error) {
|
||||
var output elbv2.RegisterTargetsOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if len(input.Targets) == 0 {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.RegisterTargets(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// These tests validate input validation in the gateway layer.
|
||||
// They do not require a NATS connection since validation happens before the NATS call.
|
||||
|
||||
func TestCreateLoadBalancer_NilInput(t *testing.T) {
|
||||
_, err := CreateLoadBalancer(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_MissingName(t *testing.T) {
|
||||
_, err := CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_EmptyName(t *testing.T) {
|
||||
_, err := CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String(""),
|
||||
}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeleteLoadBalancer_NilInput(t *testing.T) {
|
||||
_, err := DeleteLoadBalancer(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeleteLoadBalancer_MissingArn(t *testing.T) {
|
||||
_, err := DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeLoadBalancers_NilInput(t *testing.T) {
|
||||
_, err := DescribeLoadBalancers(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateTargetGroup_NilInput(t *testing.T) {
|
||||
_, err := CreateTargetGroup(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateTargetGroup_MissingName(t *testing.T) {
|
||||
_, err := CreateTargetGroup(&elbv2.CreateTargetGroupInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeleteTargetGroup_NilInput(t *testing.T) {
|
||||
_, err := DeleteTargetGroup(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeleteTargetGroup_MissingArn(t *testing.T) {
|
||||
_, err := DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeTargetGroups_NilInput(t *testing.T) {
|
||||
_, err := DescribeTargetGroups(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestRegisterTargets_NilInput(t *testing.T) {
|
||||
_, err := RegisterTargets(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestRegisterTargets_MissingArn(t *testing.T) {
|
||||
_, err := RegisterTargets(&elbv2.RegisterTargetsInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestRegisterTargets_MissingTargets(t *testing.T) {
|
||||
_, err := RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/abc"),
|
||||
}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeregisterTargets_NilInput(t *testing.T) {
|
||||
_, err := DeregisterTargets(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeregisterTargets_MissingArn(t *testing.T) {
|
||||
_, err := DeregisterTargets(&elbv2.DeregisterTargetsInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeregisterTargets_MissingTargets(t *testing.T) {
|
||||
_, err := DeregisterTargets(&elbv2.DeregisterTargetsInput{
|
||||
TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/abc"),
|
||||
}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeTargetHealth_NilInput(t *testing.T) {
|
||||
_, err := DescribeTargetHealth(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDescribeTargetHealth_MissingArn(t *testing.T) {
|
||||
_, err := DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateListener_NilInput(t *testing.T) {
|
||||
_, err := CreateListener(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateListener_MissingLBArn(t *testing.T) {
|
||||
_, err := CreateListener(&elbv2.CreateListenerInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateListener_MissingActions(t *testing.T) {
|
||||
_, err := CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/abc"),
|
||||
}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeleteListener_NilInput(t *testing.T) {
|
||||
_, err := DeleteListener(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeleteListener_MissingArn(t *testing.T) {
|
||||
_, err := DeleteListener(&elbv2.DeleteListenerInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeListeners_NilInput(t *testing.T) {
|
||||
_, err := DescribeListeners(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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[""])
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers_ec2_instance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -28,23 +29,30 @@ import (
|
||||
)
|
||||
|
||||
const cloudInitUserDataTemplate = `#cloud-config
|
||||
{{if .SSHKey}}
|
||||
users:
|
||||
- name: {{.Username}}
|
||||
shell: /bin/bash
|
||||
shell: /bin/sh
|
||||
groups:
|
||||
- sudo
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
ssh_authorized_keys:
|
||||
- {{.SSHKey}}
|
||||
{{else}}
|
||||
chpasswd:
|
||||
expire: false
|
||||
users:
|
||||
- name: root
|
||||
password: spinifex
|
||||
type: text
|
||||
ssh_pwauth: true
|
||||
{{end}}
|
||||
|
||||
hostname: {{.Hostname}}
|
||||
manage_etc_hosts: true
|
||||
|
||||
{{if .UserDataCloudConfig}}
|
||||
|
||||
# custom userdata cloud-config
|
||||
{{.UserDataCloudConfig}}
|
||||
|
||||
{{end}}
|
||||
|
||||
{{if .UserDataScript}}
|
||||
@@ -55,7 +63,7 @@ write_files:
|
||||
{{.UserDataScript}}
|
||||
|
||||
runcmd:
|
||||
- [ "/bin/bash", "/tmp/cloud-init-startup.sh" ]
|
||||
- [ "/bin/sh", "/tmp/cloud-init-startup.sh" ]
|
||||
{{end}}
|
||||
`
|
||||
|
||||
@@ -596,30 +604,33 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
|
||||
// Generate instance metadata
|
||||
hostname := generateHostname(instance.ID)
|
||||
|
||||
// Retrieve SSH pubkey from S3
|
||||
// Retrieve SSH pubkey from S3 (optional — system instances may not have a key)
|
||||
keyName := ""
|
||||
if input.KeyName != nil {
|
||||
keyName = *input.KeyName
|
||||
}
|
||||
|
||||
keyPath := fmt.Sprintf("keys/%s/%s", instance.AccountID, keyName)
|
||||
result, err := s.objectStore.GetObject(&awss3.GetObjectInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Key: aws.String(keyPath),
|
||||
})
|
||||
if err != nil {
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
slog.Error("key pair not found", "keyName", keyName, "err", err)
|
||||
return errors.New(awserrors.ErrorInvalidKeyPairNotFound)
|
||||
var sshKey []byte
|
||||
if keyName != "" {
|
||||
keyPath := fmt.Sprintf("keys/%s/%s", instance.AccountID, keyName)
|
||||
result, err := s.objectStore.GetObject(&awss3.GetObjectInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Key: aws.String(keyPath),
|
||||
})
|
||||
if err != nil {
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
slog.Error("key pair not found", "keyName", keyName, "err", err)
|
||||
return errors.New(awserrors.ErrorInvalidKeyPairNotFound)
|
||||
}
|
||||
slog.Error("failed to read SSH key", "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
slog.Error("failed to read SSH key", "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
sshKey, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
slog.Error("failed to read SSH key body", "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
sshKey, err = io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
slog.Error("failed to read SSH key body", "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
}
|
||||
|
||||
userData := CloudInitData{
|
||||
@@ -628,6 +639,28 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
|
||||
Hostname: hostname,
|
||||
}
|
||||
|
||||
// Decode and classify user-data from RunInstances (base64-encoded).
|
||||
if input.UserData != nil && *input.UserData != "" {
|
||||
decoded, decErr := base64.StdEncoding.DecodeString(*input.UserData)
|
||||
if decErr != nil {
|
||||
slog.Warn("Failed to decode user-data, ignoring", "err", decErr)
|
||||
} else {
|
||||
raw := string(decoded)
|
||||
if strings.HasPrefix(raw, "#cloud-config") {
|
||||
// Strip the #cloud-config header — the template already has it
|
||||
stripped := strings.TrimPrefix(raw, "#cloud-config")
|
||||
userData.UserDataCloudConfig = strings.TrimSpace(stripped)
|
||||
} else {
|
||||
// Script — indent each line by 4 spaces for YAML write_files block
|
||||
var indented strings.Builder
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
indented.WriteString(" " + line + "\n")
|
||||
}
|
||||
userData.UserDataScript = indented.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
t := template.Must(template.New("cloud-init").Parse(cloudInitUserDataTemplate))
|
||||
|
||||
|
||||
@@ -201,7 +201,6 @@ func TestCloudInitTemplateRendering(t *testing.T) {
|
||||
contains: []string{
|
||||
"packages:",
|
||||
"nginx",
|
||||
"custom userdata cloud-config",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateHAProxyConfig_SingleListenerAndBackend(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-abc123",
|
||||
Name: "my-alb",
|
||||
}
|
||||
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/lb-abc123/lst-111",
|
||||
LoadBalancerArn: lb.LoadBalancerArn,
|
||||
Protocol: ProtocolHTTP,
|
||||
Port: 80,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/my-tg/tg-222"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
"arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/my-tg/tg-222": {
|
||||
TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/my-tg/tg-222",
|
||||
Name: "my-tg",
|
||||
HealthCheck: HealthCheckConfig{
|
||||
Path: "/health",
|
||||
Matcher: "200",
|
||||
IntervalSeconds: 30,
|
||||
UnhealthyThreshold: 2,
|
||||
HealthyThreshold: 5,
|
||||
},
|
||||
Targets: []Target{
|
||||
{Id: "i-aaa111", Port: 8080, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
|
||||
{Id: "i-bbb222", Port: 8080, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "10.0.1.5")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify global section
|
||||
assert.Contains(t, config, "stats socket")
|
||||
assert.Contains(t, config, "alb-lb-abc123.sock")
|
||||
|
||||
// Verify frontend
|
||||
assert.Contains(t, config, "bind *:80")
|
||||
assert.Contains(t, config, "default_backend")
|
||||
|
||||
// Verify backend
|
||||
assert.Contains(t, config, "option httpchk GET /health")
|
||||
assert.Contains(t, config, "http-check expect status 200")
|
||||
assert.Contains(t, config, "10.0.1.10:8080")
|
||||
assert.Contains(t, config, "10.0.1.11:8080")
|
||||
assert.Contains(t, config, "check inter 30s fall 2 rise 5")
|
||||
}
|
||||
|
||||
func TestGenerateHAProxyConfig_MultipleListeners(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-multi"}
|
||||
|
||||
tgArn1 := "arn:tg1"
|
||||
tgArn2 := "arn:tg2"
|
||||
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:lst1",
|
||||
Port: 80,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: tgArn1},
|
||||
},
|
||||
},
|
||||
{
|
||||
ListenerArn: "arn:lst2",
|
||||
Port: 8080,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeForward, TargetGroupArn: tgArn2},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn1: {
|
||||
HealthCheck: DefaultHealthCheck(),
|
||||
Targets: []Target{{Id: "i-1", Port: 80, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy}},
|
||||
},
|
||||
tgArn2: {
|
||||
HealthCheck: DefaultHealthCheck(),
|
||||
Targets: []Target{{Id: "i-2", Port: 9090, PrivateIP: "10.0.0.2", HealthState: TargetHealthHealthy}},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "0.0.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, "bind *:80")
|
||||
assert.Contains(t, config, "bind *:8080")
|
||||
assert.Contains(t, config, "10.0.0.1:80")
|
||||
assert.Contains(t, config, "10.0.0.2:9090")
|
||||
}
|
||||
|
||||
func TestGenerateHAProxyConfig_SkipsDrainingTargets(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-drain"}
|
||||
tgArn := "arn:tg-drain"
|
||||
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:lst-drain",
|
||||
Port: 80,
|
||||
DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}},
|
||||
},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: {
|
||||
HealthCheck: DefaultHealthCheck(),
|
||||
Targets: []Target{
|
||||
{Id: "i-active", Port: 80, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
|
||||
{Id: "i-draining", Port: 80, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
|
||||
{Id: "i-no-ip", Port: 80, PrivateIP: "", HealthState: TargetHealthInitial},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "0.0.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, "10.0.0.1:80")
|
||||
assert.NotContains(t, config, "10.0.0.2") // draining
|
||||
assert.NotContains(t, config, "i-no-ip") // no IP
|
||||
}
|
||||
|
||||
func TestGenerateHAProxyConfig_SharedTargetGroup(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-shared"}
|
||||
tgArn := "arn:shared-tg"
|
||||
|
||||
// Two listeners pointing to same TG
|
||||
listeners := []*ListenerRecord{
|
||||
{ListenerArn: "arn:lst-a", Port: 80, DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}}},
|
||||
{ListenerArn: "arn:lst-b", Port: 443, DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}}},
|
||||
}
|
||||
|
||||
tgByArn := map[string]*TargetGroupRecord{
|
||||
tgArn: {
|
||||
HealthCheck: DefaultHealthCheck(),
|
||||
Targets: []Target{{Id: "i-1", Port: 80, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy}},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, "0.0.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Backend should only appear once
|
||||
assert.Equal(t, 1, strings.Count(config, "balance roundrobin"))
|
||||
}
|
||||
|
||||
func TestGenerateHAProxyConfig_NoListeners(t *testing.T) {
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-empty"}
|
||||
config, err := GenerateHAProxyConfig(lb, nil, nil, "0.0.0.0")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should still produce a valid config (global + defaults, no frontends/backends)
|
||||
assert.Contains(t, config, "global")
|
||||
assert.Contains(t, config, "defaults")
|
||||
assert.NotContains(t, config, "frontend")
|
||||
assert.NotContains(t, config, "backend")
|
||||
}
|
||||
|
||||
func TestSanitizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
prefix string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"bk", "tg-abc123", "bk_tg-abc123"},
|
||||
{"ft", "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/lb-123/lst-456", "bk" + ""},
|
||||
{"srv", "i-abcdef123", "srv_i-abcdef123"},
|
||||
}
|
||||
|
||||
// Just verify no panics and reasonable output
|
||||
for _, tc := range tests {
|
||||
result := sanitizeName(tc.prefix, tc.input)
|
||||
assert.True(t, strings.HasPrefix(result, tc.prefix+"_"))
|
||||
assert.NotContains(t, result, ":")
|
||||
assert.NotContains(t, result, "/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHAProxyManager_Available_NotFound(t *testing.T) {
|
||||
mgr := NewHAProxyManager(t.TempDir())
|
||||
mgr.binPath = "/nonexistent/haproxy-fake-bin"
|
||||
assert.False(t, mgr.Available())
|
||||
}
|
||||
|
||||
func TestHAProxyManager_IsRunning_UnknownLB(t *testing.T) {
|
||||
mgr := NewHAProxyManager(t.TempDir())
|
||||
assert.False(t, mgr.IsRunning("lb-unknown"))
|
||||
}
|
||||
|
||||
func TestHAProxyManager_Stop_NotRunning(t *testing.T) {
|
||||
mgr := NewHAProxyManager(t.TempDir())
|
||||
err := mgr.Stop("lb-not-running")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHAProxyManager_StopAll_Empty(t *testing.T) {
|
||||
mgr := NewHAProxyManager(t.TempDir())
|
||||
mgr.StopAll() // should not panic
|
||||
}
|
||||
|
||||
func TestHAProxyManager_PidFilePath(t *testing.T) {
|
||||
mgr := NewHAProxyManager("/tmp/haproxy-test")
|
||||
assert.Equal(t, "/tmp/haproxy-test/alb-lb-123.pid", mgr.pidFilePath("lb-123"))
|
||||
assert.Equal(t, "/tmp/haproxy-test/alb-lb-123.cfg", mgr.configFilePath("lb-123"))
|
||||
}
|
||||
|
||||
func TestHAProxyManager_Start_MissingBinary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := NewHAProxyManager(dir)
|
||||
mgr.binPath = "/nonexistent/haproxy"
|
||||
|
||||
// Write a config so the path exists
|
||||
_, err := mgr.WriteConfig("lb-test", "global\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = mgr.Start("lb-test")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "haproxy start failed")
|
||||
}
|
||||
|
||||
func TestHAProxyManager_Reload_NotRunning_FallsBackToStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := NewHAProxyManager(dir)
|
||||
mgr.binPath = "/nonexistent/haproxy"
|
||||
|
||||
// Write a config so the path exists
|
||||
_, err := mgr.WriteConfig("lb-test", "global\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reload when not running should attempt Start (which will fail due to bad binary)
|
||||
err = mgr.Reload("lb-test")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "haproxy start failed")
|
||||
}
|
||||
|
||||
func TestHAProxyManager_LifecycleWithFakeHAProxy(t *testing.T) {
|
||||
// Create a fake haproxy script that writes its PID to the pidfile
|
||||
dir := t.TempDir()
|
||||
fakeHAProxy := filepath.Join(dir, "fake-haproxy")
|
||||
script := `#!/bin/sh
|
||||
# Parse -p flag for pidfile
|
||||
PIDFILE=""
|
||||
SF_PID=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-p) PIDFILE="$2"; shift 2;;
|
||||
-D) shift;;
|
||||
-f) shift 2;;
|
||||
-c) exit 0;;
|
||||
-sf) SF_PID="$2"; shift 2;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
# Kill old process if -sf was passed
|
||||
if [ -n "$SF_PID" ]; then
|
||||
kill "$SF_PID" 2>/dev/null || true
|
||||
fi
|
||||
# Start a background sleep with closed fds so parent can exit
|
||||
sleep 60 </dev/null >/dev/null 2>&1 &
|
||||
BGPID=$!
|
||||
if [ -n "$PIDFILE" ]; then
|
||||
echo "$BGPID" > "$PIDFILE"
|
||||
fi
|
||||
`
|
||||
require.NoError(t, os.WriteFile(fakeHAProxy, []byte(script), 0o755))
|
||||
|
||||
configDir := filepath.Join(dir, "configs")
|
||||
mgr := NewHAProxyManager(configDir)
|
||||
mgr.binPath = fakeHAProxy
|
||||
|
||||
lbID := "lb-lifecycle-test"
|
||||
|
||||
// Write config
|
||||
content := "global\n log stdout\n"
|
||||
_, err := mgr.WriteConfig(lbID, content)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Not running yet
|
||||
assert.False(t, mgr.IsRunning(lbID))
|
||||
|
||||
// Start
|
||||
err = mgr.Start(lbID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mgr.IsRunning(lbID))
|
||||
|
||||
// Reload
|
||||
err = mgr.Reload(lbID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mgr.IsRunning(lbID))
|
||||
|
||||
// Stop
|
||||
err = mgr.Stop(lbID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, mgr.IsRunning(lbID))
|
||||
|
||||
// Pidfile should be cleaned up
|
||||
_, err = os.Stat(mgr.pidFilePath(lbID))
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestHAProxyManager_WriteAndRemoveConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := &HAProxyManager{configDir: dir, binPath: "/usr/sbin/haproxy"}
|
||||
|
||||
content := "global\n log stdout\n"
|
||||
path, err := mgr.WriteConfig("lb-test", content)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(dir, "alb-lb-test.cfg"), path)
|
||||
|
||||
// Verify file contents
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, string(data))
|
||||
|
||||
// Remove
|
||||
err = mgr.RemoveConfig("lb-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(path)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestHAProxyManager_RemoveConfig_Idempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := &HAProxyManager{configDir: dir, binPath: "/usr/sbin/haproxy"}
|
||||
|
||||
err := mgr.RemoveConfig("nonexistent")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,22 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import "github.com/aws/aws-sdk-go/service/elbv2"
|
||||
|
||||
// ELBv2Service defines the interface for Application Load Balancer operations.
|
||||
type ELBv2Service interface {
|
||||
CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, accountID string) (*elbv2.CreateLoadBalancerOutput, error)
|
||||
DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, accountID string) (*elbv2.DeleteLoadBalancerOutput, error)
|
||||
DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error)
|
||||
|
||||
CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error)
|
||||
DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, accountID string) (*elbv2.DeleteTargetGroupOutput, error)
|
||||
DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, accountID string) (*elbv2.DescribeTargetGroupsOutput, error)
|
||||
|
||||
RegisterTargets(input *elbv2.RegisterTargetsInput, accountID string) (*elbv2.RegisterTargetsOutput, error)
|
||||
DeregisterTargets(input *elbv2.DeregisterTargetsInput, accountID string) (*elbv2.DeregisterTargetsOutput, error)
|
||||
DescribeTargetHealth(input *elbv2.DescribeTargetHealthInput, accountID string) (*elbv2.DescribeTargetHealthOutput, error)
|
||||
|
||||
CreateListener(input *elbv2.CreateListenerInput, accountID string) (*elbv2.CreateListenerOutput, error)
|
||||
DeleteListener(input *elbv2.DeleteListenerInput, accountID string) (*elbv2.DeleteListenerOutput, error)
|
||||
DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupTestServiceWithInstance creates an ELBv2 + VPC service with a VPC, subnet,
|
||||
// and a simulated instance ENI (attached to an instance ID).
|
||||
func setupTestServiceWithInstance(t *testing.T, instanceID, instanceIP string) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl, string) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
elbv2Svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
elbv2Svc.SetVPCService(vpcSvc)
|
||||
|
||||
// Create VPC + subnet
|
||||
vpcOut, err := vpcSvc.CreateVpc(&ec2.CreateVpcInput{
|
||||
CidrBlock: aws.String("10.0.0.0/16"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
subnetOut, err := vpcSvc.CreateSubnet(&ec2.CreateSubnetInput{
|
||||
VpcId: vpcOut.Vpc.VpcId,
|
||||
CidrBlock: aws.String("10.0.1.0/24"),
|
||||
AvailabilityZone: aws.String("us-east-1a"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an ENI for the "instance" with specific IP
|
||||
eniOut, err := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: subnetOut.Subnet.SubnetId,
|
||||
PrivateIpAddress: aws.String(instanceIP),
|
||||
Description: aws.String("Primary ENI for " + instanceID),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attach ENI to instance (simulates RunInstances)
|
||||
_, err = vpcSvc.AttachENI(testAccountID, *eniOut.NetworkInterface.NetworkInterfaceId, instanceID, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
return elbv2Svc, vpcSvc, *subnetOut.Subnet.SubnetId
|
||||
}
|
||||
|
||||
func TestRegisterTargets_ResolvesPrivateIP(t *testing.T) {
|
||||
svc, _, _ := setupTestServiceWithInstance(t, "i-web001", "10.0.1.50")
|
||||
|
||||
// Create target group
|
||||
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("ip-resolve-tg"),
|
||||
Port: aws.Int64(80),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
// Register the instance
|
||||
_, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-web001")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify private IP was resolved
|
||||
health, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
|
||||
TargetGroupArn: tgArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, health.TargetHealthDescriptions, 1)
|
||||
|
||||
// The target should have been registered with the resolved IP in the store
|
||||
tg, err := svc.store.GetTargetGroupByArn(*tgArn)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tg.Targets, 1)
|
||||
assert.Equal(t, "10.0.1.50", tg.Targets[0].PrivateIP)
|
||||
}
|
||||
|
||||
func TestRegisterTargets_UnresolvableIP(t *testing.T) {
|
||||
svc, _, _ := setupTestServiceWithInstance(t, "i-web001", "10.0.1.50")
|
||||
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("unresolvable-tg"),
|
||||
Port: aws.Int64(80),
|
||||
}, testAccountID)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
// Register an instance that doesn't exist — should still succeed with empty IP
|
||||
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-nonexistent")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tg, _ := svc.store.GetTargetGroupByArn(*tgArn)
|
||||
require.Len(t, tg.Targets, 1)
|
||||
assert.Equal(t, "", tg.Targets[0].PrivateIP)
|
||||
}
|
||||
|
||||
func TestRegisterTargets_WithoutVPCService(t *testing.T) {
|
||||
// When VPC service is nil, IP resolution is skipped gracefully
|
||||
svc := setupTestService(t)
|
||||
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("no-vpc-tg"),
|
||||
Port: aws.Int64(80),
|
||||
}, testAccountID)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-any")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tg, _ := svc.store.GetTargetGroupByArn(*tgArn)
|
||||
assert.Equal(t, "", tg.Targets[0].PrivateIP)
|
||||
}
|
||||
|
||||
func TestRegisterTargets_MultipleInstances(t *testing.T) {
|
||||
svc, vpcSvc, _ := setupTestServiceWithInstance(t, "i-web001", "10.0.1.50")
|
||||
|
||||
// Create a second instance ENI
|
||||
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
|
||||
subnetID := subnets.Subnets[0].SubnetId
|
||||
|
||||
eni2, _ := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: subnetID,
|
||||
PrivateIpAddress: aws.String("10.0.1.51"),
|
||||
}, testAccountID)
|
||||
vpcSvc.AttachENI(testAccountID, *eni2.NetworkInterface.NetworkInterfaceId, "i-web002", 0)
|
||||
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("multi-inst-tg"),
|
||||
Port: aws.Int64(80),
|
||||
}, testAccountID)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{
|
||||
{Id: aws.String("i-web001")},
|
||||
{Id: aws.String("i-web002")},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tg, _ := svc.store.GetTargetGroupByArn(*tgArn)
|
||||
require.Len(t, tg.Targets, 2)
|
||||
assert.Equal(t, "10.0.1.50", tg.Targets[0].PrivateIP)
|
||||
assert.Equal(t, "10.0.1.51", tg.Targets[1].PrivateIP)
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) *ELBv2ServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
return svc
|
||||
}
|
||||
|
||||
// --- Load Balancer tests ---
|
||||
|
||||
func TestCreateLoadBalancer(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("my-alb"),
|
||||
Subnets: []*string{aws.String("subnet-aaa")},
|
||||
SecurityGroups: []*string{aws.String("sg-111")},
|
||||
}, testAccountID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LoadBalancers, 1)
|
||||
lb := out.LoadBalancers[0]
|
||||
assert.Equal(t, "my-alb", *lb.LoadBalancerName)
|
||||
assert.Equal(t, "internet-facing", *lb.Scheme)
|
||||
assert.Equal(t, "application", *lb.Type)
|
||||
assert.Equal(t, "active", *lb.State.Code)
|
||||
assert.Contains(t, *lb.DNSName, "my-alb")
|
||||
assert.Contains(t, *lb.LoadBalancerArn, "loadbalancer/app/my-alb")
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_InternalScheme(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("internal-alb"),
|
||||
Scheme: aws.String("internal"),
|
||||
}, testAccountID)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "internal", *out.LoadBalancers[0].Scheme)
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_InvalidScheme(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("bad-scheme"),
|
||||
Scheme: aws.String("banana"),
|
||||
}, testAccountID)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "InvalidScheme")
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_DuplicateName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("dup-alb"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("dup-alb"),
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "DuplicateLoadBalancerName")
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_MissingName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_WithTags(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("tagged-alb"),
|
||||
Tags: []*elbv2.Tag{
|
||||
{Key: aws.String("Env"), Value: aws.String("test")},
|
||||
},
|
||||
}, testAccountID)
|
||||
|
||||
require.NoError(t, err)
|
||||
// Tags are stored internally, verify via describe
|
||||
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
|
||||
Names: []*string{aws.String("tagged-alb")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.LoadBalancers, 1)
|
||||
assert.Equal(t, *out.LoadBalancers[0].LoadBalancerArn, *desc.LoadBalancers[0].LoadBalancerArn)
|
||||
}
|
||||
|
||||
func TestDeleteLoadBalancer(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("delete-me"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: out.LoadBalancers[0].LoadBalancerArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, desc.LoadBalancers)
|
||||
}
|
||||
|
||||
func TestDeleteLoadBalancer_NotFound(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/nope/xyz"),
|
||||
}, testAccountID)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "LoadBalancerNotFound")
|
||||
}
|
||||
|
||||
func TestDeleteLoadBalancer_CleansUpListeners(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
// Create LB, TG, and listener
|
||||
lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-cleanup")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
|
||||
|
||||
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-cleanup")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbArn,
|
||||
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete LB should clean up listener
|
||||
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lbArn}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Listener should be gone
|
||||
lstDesc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{LoadBalancerArn: lbArn}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, lstDesc.Listeners)
|
||||
}
|
||||
|
||||
func TestDescribeLoadBalancers_FilterByName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("alb-one")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("alb-two")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
|
||||
Names: []*string{aws.String("alb-one")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.LoadBalancers, 1)
|
||||
assert.Equal(t, "alb-one", *desc.LoadBalancers[0].LoadBalancerName)
|
||||
}
|
||||
|
||||
func TestDescribeLoadBalancers_AccountIsolation(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("acct-alb")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
desc, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, "999999999999")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, desc.LoadBalancers)
|
||||
}
|
||||
|
||||
// --- Target Group tests ---
|
||||
|
||||
func TestCreateTargetGroup(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("my-tg"),
|
||||
Protocol: aws.String("HTTP"),
|
||||
Port: aws.Int64(8080),
|
||||
VpcId: aws.String("vpc-test"),
|
||||
}, testAccountID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.TargetGroups, 1)
|
||||
tg := out.TargetGroups[0]
|
||||
assert.Equal(t, "my-tg", *tg.TargetGroupName)
|
||||
assert.Equal(t, "HTTP", *tg.Protocol)
|
||||
assert.Equal(t, int64(8080), *tg.Port)
|
||||
assert.Equal(t, "vpc-test", *tg.VpcId)
|
||||
assert.Equal(t, "/", *tg.HealthCheckPath)
|
||||
assert.Equal(t, "200", *tg.Matcher.HttpCode)
|
||||
}
|
||||
|
||||
func TestCreateTargetGroup_CustomHealthCheck(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("custom-hc"),
|
||||
HealthCheckPath: aws.String("/healthz"),
|
||||
HealthCheckIntervalSeconds: aws.Int64(10),
|
||||
HealthyThresholdCount: aws.Int64(2),
|
||||
Matcher: &elbv2.Matcher{HttpCode: aws.String("200-299")},
|
||||
}, testAccountID)
|
||||
|
||||
require.NoError(t, err)
|
||||
tg := out.TargetGroups[0]
|
||||
assert.Equal(t, "/healthz", *tg.HealthCheckPath)
|
||||
assert.Equal(t, int64(10), *tg.HealthCheckIntervalSeconds)
|
||||
assert.Equal(t, int64(2), *tg.HealthyThresholdCount)
|
||||
assert.Equal(t, "200-299", *tg.Matcher.HttpCode)
|
||||
}
|
||||
|
||||
func TestCreateTargetGroup_DuplicateName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("dup-tg"),
|
||||
VpcId: aws.String("vpc-1"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("dup-tg"),
|
||||
VpcId: aws.String("vpc-1"),
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "DuplicateTargetGroupName")
|
||||
}
|
||||
|
||||
func TestDeleteTargetGroup(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("del-tg")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
|
||||
TargetGroupArn: out.TargetGroups[0].TargetGroupArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteTargetGroup_InUse(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("inuse-tg")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("inuse-lb")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
|
||||
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ResourceInUse")
|
||||
}
|
||||
|
||||
func TestDeleteTargetGroup_NotFound(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
|
||||
TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/nope/xyz"),
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "TargetGroupNotFound")
|
||||
}
|
||||
|
||||
func TestDescribeTargetGroups_FilterByLBArn(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
tg1, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-linked")}, testAccountID)
|
||||
_, _ = svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-unlinked")}, testAccountID)
|
||||
|
||||
lb, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-filter")}, testAccountID)
|
||||
_, _ = svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
|
||||
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg1.TargetGroups[0].TargetGroupArn}},
|
||||
}, testAccountID)
|
||||
|
||||
desc, err := svc.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{
|
||||
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.TargetGroups, 1)
|
||||
assert.Equal(t, "tg-linked", *desc.TargetGroups[0].TargetGroupName)
|
||||
}
|
||||
|
||||
// --- Target registration tests ---
|
||||
|
||||
func TestRegisterTargets(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("reg-tg"),
|
||||
Port: aws.Int64(80),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
|
||||
Targets: []*elbv2.TargetDescription{
|
||||
{Id: aws.String("i-aaa111")},
|
||||
{Id: aws.String("i-bbb222"), Port: aws.Int64(8080)},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify via DescribeTargetHealth
|
||||
health, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
|
||||
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, health.TargetHealthDescriptions, 2)
|
||||
|
||||
// First target should use TG default port
|
||||
assert.Equal(t, "i-aaa111", *health.TargetHealthDescriptions[0].Target.Id)
|
||||
assert.Equal(t, int64(80), *health.TargetHealthDescriptions[0].Target.Port)
|
||||
assert.Equal(t, "initial", *health.TargetHealthDescriptions[0].TargetHealth.State)
|
||||
|
||||
// Second target should use override port
|
||||
assert.Equal(t, "i-bbb222", *health.TargetHealthDescriptions[1].Target.Id)
|
||||
assert.Equal(t, int64(8080), *health.TargetHealthDescriptions[1].Target.Port)
|
||||
}
|
||||
|
||||
func TestRegisterTargets_Idempotent(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("idem-tg")}, testAccountID)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
targets := []*elbv2.TargetDescription{{Id: aws.String("i-same")}}
|
||||
|
||||
_, err := svc.RegisterTargets(&elbv2.RegisterTargetsInput{TargetGroupArn: tgArn, Targets: targets}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{TargetGroupArn: tgArn, Targets: targets}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
health, _ := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{TargetGroupArn: tgArn}, testAccountID)
|
||||
assert.Len(t, health.TargetHealthDescriptions, 1)
|
||||
}
|
||||
|
||||
func TestDeregisterTargets(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dereg-tg"), Port: aws.Int64(80)}, testAccountID)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
svc.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{
|
||||
{Id: aws.String("i-keep")},
|
||||
{Id: aws.String("i-remove")},
|
||||
},
|
||||
}, testAccountID)
|
||||
|
||||
_, err := svc.DeregisterTargets(&elbv2.DeregisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-remove")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
health, _ := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{TargetGroupArn: tgArn}, testAccountID)
|
||||
require.Len(t, health.TargetHealthDescriptions, 1)
|
||||
assert.Equal(t, "i-keep", *health.TargetHealthDescriptions[0].Target.Id)
|
||||
}
|
||||
|
||||
func TestDescribeTargetHealth_FilterByTarget(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("filter-tg"), Port: aws.Int64(80)}, testAccountID)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
svc.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{
|
||||
{Id: aws.String("i-one")},
|
||||
{Id: aws.String("i-two")},
|
||||
},
|
||||
}, testAccountID)
|
||||
|
||||
health, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-one")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, health.TargetHealthDescriptions, 1)
|
||||
assert.Equal(t, "i-one", *health.TargetHealthDescriptions[0].Target.Id)
|
||||
}
|
||||
|
||||
func TestDescribeTargetHealth_TGNotFound(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
|
||||
TargetGroupArn: aws.String("arn:nonexistent"),
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "TargetGroupNotFound")
|
||||
}
|
||||
|
||||
// --- Listener tests ---
|
||||
|
||||
func TestCreateListener(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lst-lb")}, testAccountID)
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("lst-tg")}, testAccountID)
|
||||
|
||||
out, err := svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
Protocol: aws.String("HTTP"),
|
||||
Port: aws.Int64(8080),
|
||||
DefaultActions: []*elbv2.Action{
|
||||
{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
|
||||
},
|
||||
}, testAccountID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Listeners, 1)
|
||||
l := out.Listeners[0]
|
||||
assert.Equal(t, "HTTP", *l.Protocol)
|
||||
assert.Equal(t, int64(8080), *l.Port)
|
||||
assert.Equal(t, *lbOut.LoadBalancers[0].LoadBalancerArn, *l.LoadBalancerArn)
|
||||
require.Len(t, l.DefaultActions, 1)
|
||||
assert.Equal(t, "forward", *l.DefaultActions[0].Type)
|
||||
}
|
||||
|
||||
func TestCreateListener_DuplicatePort(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dup-port-lb")}, testAccountID)
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dup-port-tg")}, testAccountID)
|
||||
|
||||
actions := []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}}
|
||||
|
||||
_, err := svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
Port: aws.Int64(80),
|
||||
DefaultActions: actions,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
Port: aws.Int64(80),
|
||||
DefaultActions: actions,
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "DuplicateListener")
|
||||
}
|
||||
|
||||
func TestCreateListener_LBNotFound(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: aws.String("arn:nonexistent"),
|
||||
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: aws.String("arn:tg")}},
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "LoadBalancerNotFound")
|
||||
}
|
||||
|
||||
func TestDeleteListener(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dellst-lb")}, testAccountID)
|
||||
tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dellst-tg")}, testAccountID)
|
||||
|
||||
lstOut, err := svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeleteListener(&elbv2.DeleteListenerInput{
|
||||
ListenerArn: lstOut.Listeners[0].ListenerArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify deleted
|
||||
desc, _ := svc.DescribeListeners(&elbv2.DescribeListenersInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
}, testAccountID)
|
||||
assert.Empty(t, desc.Listeners)
|
||||
}
|
||||
|
||||
func TestDeleteListener_NotFound(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.DeleteListener(&elbv2.DeleteListenerInput{
|
||||
ListenerArn: aws.String("arn:nonexistent"),
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ListenerNotFound")
|
||||
}
|
||||
|
||||
func TestDescribeListeners_FilterByLBArn(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lb1, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dl-lb1")}, testAccountID)
|
||||
lb2, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("dl-lb2")}, testAccountID)
|
||||
tg, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("dl-tg")}, testAccountID)
|
||||
actions := []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn}}
|
||||
|
||||
svc.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lb1.LoadBalancers[0].LoadBalancerArn, Port: aws.Int64(80), DefaultActions: actions}, testAccountID)
|
||||
svc.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lb1.LoadBalancers[0].LoadBalancerArn, Port: aws.Int64(443), DefaultActions: actions}, testAccountID)
|
||||
svc.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lb2.LoadBalancers[0].LoadBalancerArn, Port: aws.Int64(80), DefaultActions: actions}, testAccountID)
|
||||
|
||||
desc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{
|
||||
LoadBalancerArn: lb1.LoadBalancers[0].LoadBalancerArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, desc.Listeners, 2)
|
||||
}
|
||||
|
||||
func TestDescribeListeners_AccountIsolation(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
lb, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("iso-lb")}, testAccountID)
|
||||
tg, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("iso-tg")}, testAccountID)
|
||||
svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
|
||||
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn}},
|
||||
}, testAccountID)
|
||||
|
||||
desc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{}, "999999999999")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, desc.Listeners)
|
||||
}
|
||||
|
||||
// --- HAProxy sync tests ---
|
||||
|
||||
func TestCreateListener_PushConfig_NoNATS(t *testing.T) {
|
||||
// When NATS conn is nil, CreateListener should still succeed
|
||||
// (pushConfig is a no-op when nc is nil or InstanceID is empty)
|
||||
svc := setupTestService(t)
|
||||
|
||||
lb, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("sync-lb"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tg, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("sync-tg"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lb.LoadBalancers[0].LoadBalancerArn,
|
||||
Protocol: aws.String("HTTP"),
|
||||
Port: aws.Int64(80),
|
||||
DefaultActions: []*elbv2.Action{
|
||||
{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err) // No panic, no error — pushConfig skipped gracefully
|
||||
}
|
||||
|
||||
func TestDeleteLoadBalancer_TerminatesALBVM(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
// Set up a mock instance launcher
|
||||
mock := &mockTerminateLauncher{}
|
||||
svc.SetInstanceLauncher(mock)
|
||||
|
||||
lb, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("del-lb"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
lbArn := *lb.LoadBalancers[0].LoadBalancerArn
|
||||
|
||||
// Delete — since no ALB VM was launched (no systemAMI), InstanceID is empty,
|
||||
// so terminate is not called. This verifies the nil-safe path.
|
||||
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: aws.String(lbArn),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// No terminate call expected (no instance ID)
|
||||
assert.Equal(t, 0, len(mock.terminateCalls))
|
||||
}
|
||||
|
||||
// mockTerminateLauncher records TerminateSystemInstance calls for testing.
|
||||
type mockTerminateLauncher struct {
|
||||
terminateCalls []string
|
||||
}
|
||||
|
||||
func (m *mockTerminateLauncher) LaunchSystemInstance(_ *SystemInstanceInput) (*SystemInstanceOutput, error) {
|
||||
return &SystemInstanceOutput{InstanceID: "i-mock", PrivateIP: "10.0.0.1"}, nil
|
||||
}
|
||||
|
||||
func (m *mockTerminateLauncher) TerminateSystemInstance(instanceID string) error {
|
||||
m.terminateCalls = append(m.terminateCalls, instanceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupTestServiceWithVPC creates an ELBv2 service wired to a real VPC service
|
||||
// with a pre-created VPC and subnet for ENI allocation testing.
|
||||
func setupTestServiceWithVPC(t *testing.T) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
// Create VPC service
|
||||
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create ELBv2 service with VPC wired in
|
||||
elbv2Svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
elbv2Svc.SetVPCService(vpcSvc)
|
||||
|
||||
// Create a VPC and subnet for tests
|
||||
vpcOut, err := vpcSvc.CreateVpc(&ec2.CreateVpcInput{
|
||||
CidrBlock: aws.String("10.0.0.0/16"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = vpcSvc.CreateSubnet(&ec2.CreateSubnetInput{
|
||||
VpcId: vpcOut.Vpc.VpcId,
|
||||
CidrBlock: aws.String("10.0.1.0/24"),
|
||||
AvailabilityZone: aws.String("us-east-1a"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return elbv2Svc, vpcSvc
|
||||
}
|
||||
|
||||
// getTestSubnetID creates a fresh subnet and returns its ID.
|
||||
func getTestSubnetID(t *testing.T, vpcSvc *handlers_ec2_vpc.VPCServiceImpl, vpcID, cidr, az string) string {
|
||||
t.Helper()
|
||||
out, err := vpcSvc.CreateSubnet(&ec2.CreateSubnetInput{
|
||||
VpcId: aws.String(vpcID),
|
||||
CidrBlock: aws.String(cidr),
|
||||
AvailabilityZone: aws.String(az),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return *out.Subnet.SubnetId
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_CreatesENIs(t *testing.T) {
|
||||
svc, vpcSvc := setupTestServiceWithVPC(t)
|
||||
|
||||
// Find the subnet we created
|
||||
subnets, err := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, subnets.Subnets)
|
||||
subnetID := *subnets.Subnets[0].SubnetId
|
||||
|
||||
// Create ALB with subnet
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("eni-test-alb"),
|
||||
Subnets: []*string{aws.String(subnetID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LoadBalancers, 1)
|
||||
lb := out.LoadBalancers[0]
|
||||
|
||||
// Verify VpcId was populated from subnet
|
||||
assert.NotEmpty(t, *lb.VpcId)
|
||||
|
||||
// Verify AZ info was populated
|
||||
require.Len(t, lb.AvailabilityZones, 1)
|
||||
assert.Equal(t, "us-east-1a", *lb.AvailabilityZones[0].ZoneName)
|
||||
assert.Equal(t, subnetID, *lb.AvailabilityZones[0].SubnetId)
|
||||
|
||||
// Verify ENI was created
|
||||
eniDesc, err := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, eniDesc.NetworkInterfaces, 1)
|
||||
|
||||
eni := eniDesc.NetworkInterfaces[0]
|
||||
assert.Contains(t, *eni.Description, "ELB app/eni-test-alb/")
|
||||
assert.True(t, *eni.RequesterManaged)
|
||||
assert.Equal(t, subnetID, *eni.SubnetId)
|
||||
assert.NotEmpty(t, *eni.PrivateIpAddress)
|
||||
|
||||
// Verify ENI has the managed-by tag
|
||||
foundTag := false
|
||||
for _, tag := range eni.TagSet {
|
||||
if *tag.Key == "spinifex:managed-by" && *tag.Value == "elbv2" {
|
||||
foundTag = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundTag, "ENI should have spinifex:managed-by=elbv2 tag")
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_MultipleSubnets(t *testing.T) {
|
||||
svc, vpcSvc := setupTestServiceWithVPC(t)
|
||||
|
||||
// Get VPC ID
|
||||
vpcs, _ := vpcSvc.DescribeVpcs(&ec2.DescribeVpcsInput{}, testAccountID)
|
||||
vpcID := *vpcs.Vpcs[0].VpcId
|
||||
|
||||
// Create two subnets in different AZs
|
||||
sub1 := getTestSubnetID(t, vpcSvc, vpcID, "10.0.2.0/24", "us-east-1a")
|
||||
sub2 := getTestSubnetID(t, vpcSvc, vpcID, "10.0.3.0/24", "us-east-1b")
|
||||
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("multi-subnet-alb"),
|
||||
Subnets: []*string{aws.String(sub1), aws.String(sub2)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
lb := out.LoadBalancers[0]
|
||||
assert.Len(t, lb.AvailabilityZones, 2)
|
||||
|
||||
// Verify 2 ENIs created (+ 1 from setupTestServiceWithVPC's initial subnet)
|
||||
eniDesc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
|
||||
managedCount := 0
|
||||
for _, eni := range eniDesc.NetworkInterfaces {
|
||||
if eni.RequesterManaged != nil && *eni.RequesterManaged {
|
||||
managedCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, managedCount)
|
||||
}
|
||||
|
||||
func TestDeleteLoadBalancer_CleansUpENIs(t *testing.T) {
|
||||
svc, vpcSvc := setupTestServiceWithVPC(t)
|
||||
|
||||
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
|
||||
subnetID := *subnets.Subnets[0].SubnetId
|
||||
|
||||
// Create and then delete ALB
|
||||
lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("cleanup-alb"),
|
||||
Subnets: []*string{aws.String(subnetID)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify ENI exists
|
||||
eniDesc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
|
||||
assert.Len(t, eniDesc.NetworkInterfaces, 1)
|
||||
|
||||
// Delete ALB
|
||||
_, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify ENI was cleaned up
|
||||
eniDesc, _ = vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
|
||||
assert.Empty(t, eniDesc.NetworkInterfaces)
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_InvalidSubnet(t *testing.T) {
|
||||
svc, _ := setupTestServiceWithVPC(t)
|
||||
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("bad-subnet-alb"),
|
||||
Subnets: []*string{aws.String("subnet-nonexistent")},
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "SubnetNotFound")
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_RollbackOnPartialFailure(t *testing.T) {
|
||||
svc, vpcSvc := setupTestServiceWithVPC(t)
|
||||
|
||||
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
|
||||
validSubnet := *subnets.Subnets[0].SubnetId
|
||||
|
||||
// First subnet valid, second invalid — should rollback the first ENI
|
||||
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("rollback-alb"),
|
||||
Subnets: []*string{aws.String(validSubnet), aws.String("subnet-bogus")},
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Verify no orphaned ENIs remain
|
||||
eniDesc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
|
||||
assert.Empty(t, eniDesc.NetworkInterfaces)
|
||||
}
|
||||
|
||||
func TestCreateLoadBalancer_WithoutVPCService(t *testing.T) {
|
||||
// When vpcService is nil (e.g. in pure unit tests), ENI creation is skipped
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("no-vpc-alb"),
|
||||
Subnets: []*string{aws.String("subnet-xxx")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.LoadBalancers[0].VpcId)
|
||||
assert.Empty(t, out.LoadBalancers[0].AvailabilityZones)
|
||||
}
|
||||
|
||||
func TestENI_RequesterManagedFlag(t *testing.T) {
|
||||
_, vpcSvc := setupTestServiceWithVPC(t)
|
||||
|
||||
subnets, _ := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
|
||||
subnetID := *subnets.Subnets[0].SubnetId
|
||||
|
||||
// Create a normal ENI (not managed)
|
||||
normalENI, err := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(subnetID),
|
||||
Description: aws.String("user ENI"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a managed ENI (like ALB would)
|
||||
managedENI, err := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(subnetID),
|
||||
Description: aws.String("ELB app/test/lb123"),
|
||||
TagSpecifications: []*ec2.TagSpecification{
|
||||
{
|
||||
ResourceType: aws.String("network-interface"),
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("spinifex:managed-by"), Value: aws.String("elbv2")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Describe all ENIs
|
||||
desc, _ := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
|
||||
require.Len(t, desc.NetworkInterfaces, 2)
|
||||
|
||||
for _, eni := range desc.NetworkInterfaces {
|
||||
if *eni.NetworkInterfaceId == *normalENI.NetworkInterface.NetworkInterfaceId {
|
||||
assert.False(t, *eni.RequesterManaged, "normal ENI should not be RequesterManaged")
|
||||
}
|
||||
if *eni.NetworkInterfaceId == *managedENI.NetworkInterface.NetworkInterfaceId {
|
||||
assert.True(t, *eni.RequesterManaged, "managed ENI should be RequesterManaged")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
// NATSELBv2Service handles ELBv2 operations via NATS messaging.
|
||||
type NATSELBv2Service struct {
|
||||
natsConn *nats.Conn
|
||||
}
|
||||
|
||||
// NewNATSELBv2Service creates a new NATS-based ELBv2 service.
|
||||
func NewNATSELBv2Service(conn *nats.Conn) ELBv2Service {
|
||||
return &NATSELBv2Service{natsConn: conn}
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, accountID string) (*elbv2.CreateLoadBalancerOutput, error) {
|
||||
return utils.NATSRequest[elbv2.CreateLoadBalancerOutput](s.natsConn, "elbv2.CreateLoadBalancer", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, accountID string) (*elbv2.DeleteLoadBalancerOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DeleteLoadBalancerOutput](s.natsConn, "elbv2.DeleteLoadBalancer", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DescribeLoadBalancersOutput](s.natsConn, "elbv2.DescribeLoadBalancers", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error) {
|
||||
return utils.NATSRequest[elbv2.CreateTargetGroupOutput](s.natsConn, "elbv2.CreateTargetGroup", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, accountID string) (*elbv2.DeleteTargetGroupOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DeleteTargetGroupOutput](s.natsConn, "elbv2.DeleteTargetGroup", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, accountID string) (*elbv2.DescribeTargetGroupsOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DescribeTargetGroupsOutput](s.natsConn, "elbv2.DescribeTargetGroups", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) RegisterTargets(input *elbv2.RegisterTargetsInput, accountID string) (*elbv2.RegisterTargetsOutput, error) {
|
||||
return utils.NATSRequest[elbv2.RegisterTargetsOutput](s.natsConn, "elbv2.RegisterTargets", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DeregisterTargets(input *elbv2.DeregisterTargetsInput, accountID string) (*elbv2.DeregisterTargetsOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DeregisterTargetsOutput](s.natsConn, "elbv2.DeregisterTargets", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DescribeTargetHealth(input *elbv2.DescribeTargetHealthInput, accountID string) (*elbv2.DescribeTargetHealthOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DescribeTargetHealthOutput](s.natsConn, "elbv2.DescribeTargetHealth", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) CreateListener(input *elbv2.CreateListenerInput, accountID string) (*elbv2.CreateListenerOutput, error) {
|
||||
return utils.NATSRequest[elbv2.CreateListenerOutput](s.natsConn, "elbv2.CreateListener", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DeleteListener(input *elbv2.DeleteListenerInput, accountID string) (*elbv2.DeleteListenerOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DeleteListenerOutput](s.natsConn, "elbv2.DeleteListener", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSELBv2Service) DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error) {
|
||||
return utils.NATSRequest[elbv2.DescribeListenersOutput](s.natsConn, "elbv2.DescribeListeners", input, defaultTimeout, accountID)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupNATSELBv2Test creates a NATS client + backend service wired together,
|
||||
// exercising the full NATS serialization round-trip (same as gateway→daemon path).
|
||||
func setupNATSELBv2Test(t *testing.T) (ELBv2Service, *ELBv2ServiceImpl) {
|
||||
t.Helper()
|
||||
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
backend, err := NewELBv2ServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wire backend as NATS subscriber (simulates daemon subscriptions)
|
||||
topics := map[string]func(*nats.Msg){
|
||||
"elbv2.CreateLoadBalancer": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateLoadBalancer) },
|
||||
"elbv2.DeleteLoadBalancer": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteLoadBalancer) },
|
||||
"elbv2.DescribeLoadBalancers": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeLoadBalancers) },
|
||||
"elbv2.CreateTargetGroup": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateTargetGroup) },
|
||||
"elbv2.DeleteTargetGroup": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteTargetGroup) },
|
||||
"elbv2.DescribeTargetGroups": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeTargetGroups) },
|
||||
"elbv2.RegisterTargets": func(msg *nats.Msg) { handleNATSMsg(msg, backend.RegisterTargets) },
|
||||
"elbv2.DeregisterTargets": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeregisterTargets) },
|
||||
"elbv2.DescribeTargetHealth": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeTargetHealth) },
|
||||
"elbv2.CreateListener": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateListener) },
|
||||
"elbv2.DeleteListener": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteListener) },
|
||||
"elbv2.DescribeListeners": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeListeners) },
|
||||
}
|
||||
|
||||
for topic, handler := range topics {
|
||||
sub, err := nc.Subscribe(topic, handler)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
|
||||
client := NewNATSELBv2Service(nc)
|
||||
return client, backend
|
||||
}
|
||||
|
||||
// handleNATSMsg is the generic NATS handler that mirrors daemon's handleNATSRequest.
|
||||
// Uses GenerateErrorPayload for errors, matching the real daemon behavior.
|
||||
func handleNATSMsg[In any, Out any](msg *nats.Msg, fn func(*In, string) (*Out, error)) {
|
||||
var input In
|
||||
if err := json.Unmarshal(msg.Data, &input); err != nil {
|
||||
_ = msg.Respond(utils.GenerateErrorPayload("ServerInternal"))
|
||||
return
|
||||
}
|
||||
accountID := msg.Header.Get(utils.AccountIDHeader)
|
||||
result, err := fn(&input, accountID)
|
||||
if err != nil {
|
||||
_ = msg.Respond(utils.GenerateErrorPayload(err.Error()))
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
_ = msg.Respond(data)
|
||||
}
|
||||
|
||||
// --- Full round-trip E2E tests ---
|
||||
|
||||
func TestNATSE2E_CreateAndDescribeLoadBalancer(t *testing.T) {
|
||||
client, _ := setupNATSELBv2Test(t)
|
||||
|
||||
out, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("e2e-alb"),
|
||||
Subnets: []*string{aws.String("subnet-aaa")},
|
||||
SecurityGroups: []*string{aws.String("sg-111")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LoadBalancers, 1)
|
||||
assert.Equal(t, "e2e-alb", *out.LoadBalancers[0].LoadBalancerName)
|
||||
|
||||
desc, err := client.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
|
||||
Names: []*string{aws.String("e2e-alb")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.LoadBalancers, 1)
|
||||
assert.Equal(t, *out.LoadBalancers[0].LoadBalancerArn, *desc.LoadBalancers[0].LoadBalancerArn)
|
||||
}
|
||||
|
||||
func TestNATSE2E_FullWorkflow(t *testing.T) {
|
||||
client, _ := setupNATSELBv2Test(t)
|
||||
|
||||
// 1. Create target group
|
||||
tgOut, err := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("e2e-tg"),
|
||||
Protocol: aws.String("HTTP"),
|
||||
Port: aws.Int64(80),
|
||||
VpcId: aws.String("vpc-e2e"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tgOut.TargetGroups, 1)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
// 2. Register targets
|
||||
_, err = client.RegisterTargets(&elbv2.RegisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{
|
||||
{Id: aws.String("i-instance1")},
|
||||
{Id: aws.String("i-instance2"), Port: aws.Int64(8080)},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 3. Verify targets registered
|
||||
healthOut, err := client.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
|
||||
TargetGroupArn: tgArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, healthOut.TargetHealthDescriptions, 2)
|
||||
assert.Equal(t, "initial", *healthOut.TargetHealthDescriptions[0].TargetHealth.State)
|
||||
|
||||
// 4. Create load balancer
|
||||
lbOut, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("e2e-lb"),
|
||||
Subnets: []*string{aws.String("subnet-1")},
|
||||
SecurityGroups: []*string{aws.String("sg-1")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
|
||||
|
||||
// 5. Create listener
|
||||
lstOut, err := client.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbArn,
|
||||
Protocol: aws.String("HTTP"),
|
||||
Port: aws.Int64(80),
|
||||
DefaultActions: []*elbv2.Action{
|
||||
{Type: aws.String("forward"), TargetGroupArn: tgArn},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, lstOut.Listeners, 1)
|
||||
assert.Equal(t, int64(80), *lstOut.Listeners[0].Port)
|
||||
|
||||
// 6. Describe listeners
|
||||
lstDesc, err := client.DescribeListeners(&elbv2.DescribeListenersInput{
|
||||
LoadBalancerArn: lbArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, lstDesc.Listeners, 1)
|
||||
|
||||
// 7. Describe target groups filtered by LB
|
||||
tgDesc, err := client.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{
|
||||
LoadBalancerArn: lbArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tgDesc.TargetGroups, 1)
|
||||
assert.Equal(t, "e2e-tg", *tgDesc.TargetGroups[0].TargetGroupName)
|
||||
|
||||
// 8. Deregister one target
|
||||
_, err = client.DeregisterTargets(&elbv2.DeregisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-instance1")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
healthOut2, err := client.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
|
||||
TargetGroupArn: tgArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, healthOut2.TargetHealthDescriptions, 1)
|
||||
assert.Equal(t, "i-instance2", *healthOut2.TargetHealthDescriptions[0].Target.Id)
|
||||
|
||||
// 9. Delete listener
|
||||
_, err = client.DeleteListener(&elbv2.DeleteListenerInput{
|
||||
ListenerArn: lstOut.Listeners[0].ListenerArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 10. Delete load balancer
|
||||
_, err = client.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: lbArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 11. Delete target group (should succeed now — no listener references it)
|
||||
_, err = client.DeregisterTargets(&elbv2.DeregisterTargetsInput{
|
||||
TargetGroupArn: tgArn,
|
||||
Targets: []*elbv2.TargetDescription{{Id: aws.String("i-instance2"), Port: aws.Int64(8080)}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
|
||||
TargetGroupArn: tgArn,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 12. Verify everything is cleaned up
|
||||
lbDesc, err := client.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, lbDesc.LoadBalancers)
|
||||
|
||||
tgDesc2, err := client.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, tgDesc2.TargetGroups)
|
||||
}
|
||||
|
||||
func TestNATSE2E_DeleteLoadBalancerCascadesListeners(t *testing.T) {
|
||||
client, _ := setupNATSELBv2Test(t)
|
||||
|
||||
// Create LB + TG + 2 listeners
|
||||
tgOut, _ := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("cascade-tg"),
|
||||
}, testAccountID)
|
||||
tgArn := tgOut.TargetGroups[0].TargetGroupArn
|
||||
|
||||
lbOut, _ := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("cascade-lb"),
|
||||
}, testAccountID)
|
||||
lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
|
||||
|
||||
actions := []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgArn}}
|
||||
client.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lbArn, Port: aws.Int64(80), DefaultActions: actions}, testAccountID)
|
||||
client.CreateListener(&elbv2.CreateListenerInput{LoadBalancerArn: lbArn, Port: aws.Int64(443), DefaultActions: actions}, testAccountID)
|
||||
|
||||
// Delete LB should cascade-delete listeners
|
||||
_, err := client.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lbArn}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// TG should now be deletable (no listeners reference it)
|
||||
_, err = client.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{TargetGroupArn: tgArn}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNATSE2E_TargetGroupInUseProtection(t *testing.T) {
|
||||
client, _ := setupNATSELBv2Test(t)
|
||||
|
||||
tgOut, _ := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("protected-tg")}, testAccountID)
|
||||
lbOut, _ := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("protected-lb")}, testAccountID)
|
||||
|
||||
client.CreateListener(&elbv2.CreateListenerInput{
|
||||
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
|
||||
DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
|
||||
}, testAccountID)
|
||||
|
||||
// Should fail — TG is in use by a listener
|
||||
_, err := client.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
|
||||
TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package handlers_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Verify NATSELBv2Service implements ELBv2Service at compile time.
|
||||
var _ ELBv2Service = (*NATSELBv2Service)(nil)
|
||||
|
||||
func TestNewNATSELBv2Service_NilConn(t *testing.T) {
|
||||
// Should create the service even with nil conn (conn is used at call time, not construction)
|
||||
svc := NewNATSELBv2Service(nil)
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -674,3 +674,114 @@ Standalone VPC networking test suite. Runs against a running Spinifex endpoint (
|
||||
- `delete-subnet`
|
||||
- `delete-vpc`
|
||||
- Verify OVN cleanup (no VPC routers remaining, when OVN available)
|
||||
|
||||
---
|
||||
|
||||
## ELBv2 / ALB (`run-elbv2-e2e.sh`)
|
||||
|
||||
Standalone ELBv2 (Application Load Balancer) E2E test suite. Requires VPC networking
|
||||
for ALB ENI creation. Configurable endpoint via `ENDPOINT` env var.
|
||||
|
||||
### Phase 0: VPC + Subnet Setup
|
||||
- `create-vpc` (10.200.0.0/16, prerequisite for ALB)
|
||||
- `create-subnet` (10.200.1.0/24)
|
||||
|
||||
### Phase 1: Target Group CRUD
|
||||
- `create-target-group` (HTTP, port 80, verify health check defaults)
|
||||
- `describe-target-groups` (by ARN, verify single result)
|
||||
- `create-target-group` (second TG, port 8080)
|
||||
- `describe-target-groups` (all, verify >= 2)
|
||||
- Duplicate name detection (same name → error)
|
||||
|
||||
### Phase 2: Target Registration
|
||||
- `register-targets` (2 fake instances)
|
||||
- `describe-target-health` (verify 2 targets, initial state)
|
||||
- `deregister-targets` (remove 1 target)
|
||||
- `describe-target-health` (verify 1 target remains)
|
||||
|
||||
### Phase 3: Load Balancer CRUD
|
||||
- `create-load-balancer` (ALB with subnet)
|
||||
- Verify fields: type=application, state=active, DNS name, scheme=internet-facing
|
||||
- Verify ENIs created (describe-network-interfaces with ELB filter)
|
||||
- `describe-load-balancers` (by ARN, verify single result)
|
||||
- Duplicate name detection (same name → error)
|
||||
|
||||
### Phase 4: Listener CRUD
|
||||
- `create-listener` (port 80, forward to TG)
|
||||
- Verify fields: port=80, protocol=HTTP
|
||||
- `describe-listeners` (by LB ARN, verify single result)
|
||||
- Duplicate port detection (same port → error)
|
||||
|
||||
### Phase 5: In-Use Protection
|
||||
- `delete-target-group` while referenced by listener → error (ResourceInUse)
|
||||
|
||||
### Phase 6: Listener Deletion
|
||||
- `delete-listener`
|
||||
- Verify 0 listeners remain
|
||||
- `delete-target-group` (now succeeds after listener removed)
|
||||
|
||||
### Phase 7: Load Balancer Deletion
|
||||
- `delete-load-balancer`
|
||||
- Verify ALB gone from describe
|
||||
- Verify ENIs cleaned up
|
||||
- `delete-target-group` (second TG)
|
||||
|
||||
### Phase 8: Error Path Tests
|
||||
- Describe non-existent ALB → empty result
|
||||
- Delete non-existent ALB → error
|
||||
- Delete non-existent TG → error
|
||||
- Create TG without name → error
|
||||
- Create listener on non-existent ALB → error
|
||||
|
||||
---
|
||||
|
||||
## ELBv2 / ALB Data Plane (`run-elbv2-dataplane-e2e.sh`)
|
||||
|
||||
Data plane test that verifies ALBs actually balance HTTP traffic across instances.
|
||||
Requires VPC networking (OVN), HAProxy on daemon node, and imported AMI. Designed
|
||||
for pseudo-multinode or real multi-node environments.
|
||||
|
||||
### Phase 0: Prerequisites
|
||||
- Discover nano instance type
|
||||
- Discover AMI (must be pre-imported)
|
||||
|
||||
### Phase 1: VPC + Subnet Setup
|
||||
- `create-vpc` (10.201.0.0/16)
|
||||
- `create-subnet` (10.201.1.0/24)
|
||||
|
||||
### Phase 2: Launch Instances
|
||||
- `run-instances` x2 with cloud-init HTTP responder (Python http.server on port 80)
|
||||
- Cloud-init serves `{"instance_id": "<hostname>"}` on each instance
|
||||
- Poll both instances to running state
|
||||
- Verify both have `PrivateIpAddress` assigned
|
||||
|
||||
### Phase 3: Target Group + Registration
|
||||
- `create-target-group` (HTTP, port 80, health check on /index.html, 5s interval)
|
||||
- `register-targets` (both instances)
|
||||
|
||||
### Phase 4: ALB + Listener
|
||||
- `create-load-balancer` (ALB with subnet)
|
||||
- Resolve ALB ENI private IP (via describe-network-interfaces)
|
||||
- `create-listener` (port 80, forward to target group)
|
||||
|
||||
### Phase 5: Wait for Target Health
|
||||
- Poll `describe-target-health` until both targets healthy (120s timeout)
|
||||
- Continue even if targets stay in "initial" (HAProxy may still forward)
|
||||
|
||||
### Phase 6: Traffic Balancing — Round Robin
|
||||
- `curl` ALB ENI IP 20 times, parse `instance_id` from JSON response
|
||||
- Verify responses contain BOTH instance IDs (round-robin distribution)
|
||||
- Verify success rate >= 50%
|
||||
|
||||
### Phase 7: Single Target After Deregister
|
||||
- `deregister-targets` (remove second instance)
|
||||
- Wait for HAProxy reload (3s)
|
||||
- `curl` ALB 20 times
|
||||
- Verify ONLY the remaining instance responds
|
||||
- Verify success rate >= 50%
|
||||
|
||||
### Phase 8: Re-register + Recovery
|
||||
- `register-targets` (re-add deregistered instance)
|
||||
- Wait for HAProxy reload (5s)
|
||||
- `curl` ALB 20 times
|
||||
- Verify BOTH instances responding again
|
||||
|
||||
Executable
+663
@@ -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 ""
|
||||
Executable
+589
@@ -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 ""
|
||||
Reference in New Issue
Block a user