diff --git a/Makefile b/Makefile index 96c74df87..425da1917 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,15 @@ 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 +build-system-image: ## Build a system image from manifest (use IMAGE=alb) +ifndef IMAGE + $(error IMAGE is required. Usage: make build-system-image IMAGE=alb) +endif + ./scripts/build-system-image.sh scripts/images/$(IMAGE).conf + +build-alb-image: ## Build ALB Alpine image + $(MAKE) build-system-image IMAGE=alb + go_run: @echo -e "\n....Running $(GO_PROJECT_NAME)...." $(GOPATH)/bin/$(GO_PROJECT_NAME) @@ -198,7 +207,7 @@ distro-arm64: distro-clean: rm -rf dist/ -.PHONY: build build-ui build-alb-agent go_build go_run preflight test test-cover test-race diff-coverage bench run clean \ +.PHONY: build build-ui build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run clean \ install-system install-go install-aws quickinstall \ lint fix govulncheck \ distro distro-amd64 distro-arm64 distro-clean diff --git a/scripts/build-alb-image.sh b/scripts/build-alb-image.sh index 0d01d5b5c..4417dc81c 100755 --- a/scripts/build-alb-image.sh +++ b/scripts/build-alb-image.sh @@ -1,206 +1,4 @@ #!/bin/bash -set -euo pipefail - -# build-alb-image.sh — Build a minimal Alpine AMI for ALB VMs -# -# Creates a pre-baked Alpine Linux image with HAProxy + alb-agent installed, -# ready for import as a Spinifex AMI. ALB VMs boot in ~3s and need no internet -# access or package installation at runtime. -# -# Requirements: qemu-nbd, sudo (for mount/chroot), curl -# Usage: ./scripts/build-alb-image.sh [--import] -# --import Also import the image as an AMI via spx admin images import - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -BUILD_DIR="/tmp/alb-image-build" -ALPINE_VERSION="3.21.6" -ALPINE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2" -ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/cloud/${ALPINE_IMAGE}" -OUTPUT_IMAGE="${BUILD_DIR}/alb-alpine.qcow2" -OUTPUT_RAW="${BUILD_DIR}/alb-alpine.raw" -NBD_DEV="/dev/nbd0" -MOUNT_DIR="${BUILD_DIR}/mnt" -ALB_AGENT_BIN="${PROJECT_DIR}/bin/alb-agent" - -DO_IMPORT=false -if [[ "${1:-}" == "--import" ]]; then - DO_IMPORT=true -fi - -cleanup() { - echo "Cleaning up..." - sudo umount "${MOUNT_DIR}" 2>/dev/null || true - sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true - echo "Done." -} -trap cleanup EXIT - -echo "=== ALB Alpine Image Builder ===" -echo "Alpine version: ${ALPINE_VERSION}" -echo "Build dir: ${BUILD_DIR}" -echo "" - -# Step 0: Check prerequisites -if [ ! -f "$ALB_AGENT_BIN" ]; then - echo "Building alb-agent (static)..." - (cd "$PROJECT_DIR" && CGO_ENABLED=0 go build -o bin/alb-agent cmd/alb-agent/main.go) -else - # Verify the existing binary is statically linked - if ! file "$ALB_AGENT_BIN" | grep -q "statically linked"; then - echo "Rebuilding alb-agent as static binary (Alpine uses musl)..." - (cd "$PROJECT_DIR" && CGO_ENABLED=0 go build -o bin/alb-agent cmd/alb-agent/main.go) - fi -fi - -if ! command -v qemu-nbd &>/dev/null; then - echo "ERROR: qemu-nbd not found. Install qemu-utils." - exit 1 -fi - -mkdir -p "$BUILD_DIR" "$MOUNT_DIR" - -# Step 1: Download Alpine cloud image -if [ -f "${BUILD_DIR}/${ALPINE_IMAGE}" ]; then - echo "Alpine image already downloaded." -else - echo "Downloading Alpine ${ALPINE_VERSION} cloud image..." - curl -L -o "${BUILD_DIR}/${ALPINE_IMAGE}" "$ALPINE_URL" -fi - -# Step 2: Copy image for customization -echo "Copying image for customization..." -cp "${BUILD_DIR}/${ALPINE_IMAGE}" "$OUTPUT_IMAGE" - -# Resize the image to 512M (Alpine cloud images are ~200MB, need room for packages) -qemu-img resize "$OUTPUT_IMAGE" 512M - -# Step 3: Connect via qemu-nbd -echo "Connecting image via qemu-nbd..." -sudo modprobe nbd max_part=4 2>/dev/null || true -sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true -sudo qemu-nbd --connect="${NBD_DEV}" "$OUTPUT_IMAGE" -sleep 1 - -# Alpine cloud images have ext4 directly on the block device (no partition table). -# Resize the filesystem to fill the resized image. -echo "Resizing filesystem..." -sudo e2fsck -f -y "${NBD_DEV}" 2>/dev/null || true -sudo resize2fs "${NBD_DEV}" 2>/dev/null || true - -# Step 4: Mount and customize -echo "Mounting root filesystem..." -sudo mount "${NBD_DEV}" "$MOUNT_DIR" - -# Set up resolv.conf for DNS inside chroot -sudo cp /etc/resolv.conf "${MOUNT_DIR}/etc/resolv.conf" - -echo "Installing packages in chroot..." -sudo chroot "$MOUNT_DIR" /bin/sh -c ' -set -e -# Enable community repo (haproxy is in community) -sed -i "s|^#\(.*community\)|\1|" /etc/apk/repositories 2>/dev/null || true - -# Ensure community repo is present -if ! grep -q "community" /etc/apk/repositories; then - MIRROR=$(grep "main" /etc/apk/repositories | head -1 | sed "s|/main|/community|") - echo "$MIRROR" >> /etc/apk/repositories -fi - -apk update -apk add haproxy curl - -# Enable haproxy to start at boot (but do not start it now) -rc-update add haproxy default 2>/dev/null || true - -# Create haproxy config directory -mkdir -p /etc/haproxy -cat > /etc/haproxy/haproxy.cfg < /dev/null <<'INITSCRIPT' -#!/sbin/openrc-run - -description="ALB NATS Config Agent" -command="/usr/local/bin/alb-agent" -command_args="--lb-id=${ALB_LB_ID:-unknown} --nats=${ALB_NATS_URL:-nats://10.0.2.2:4222}" -command_background=true -pidfile="/run/alb-agent.pid" -output_log="/var/log/alb-agent.log" -error_log="/var/log/alb-agent.log" - -depend() { - need net - after firewall -} -INITSCRIPT -sudo chmod 755 "${MOUNT_DIR}/etc/init.d/alb-agent" - -# Create a cloud-init module-final script hook that starts alb-agent with the -# correct LB ID and NATS URL (passed via cloud-init user-data env vars). -# Cloud-init on Alpine runs runcmd which we'll use to start the agent. - -# Step 6: Clean up and unmount -echo "Cleaning up image..." -sudo chroot "$MOUNT_DIR" /bin/sh -c ' -apk cache clean 2>/dev/null || true -rm -rf /var/cache/apk/* /tmp/* -' - -# Restore original resolv.conf (cloud-init will set it on boot) -sudo rm -f "${MOUNT_DIR}/etc/resolv.conf" - -echo "Unmounting..." -sudo umount "$MOUNT_DIR" -sudo qemu-nbd --disconnect "${NBD_DEV}" - -# Step 7: Convert to raw for import -echo "Converting to raw format..." -qemu-img convert -f qcow2 -O raw "$OUTPUT_IMAGE" "$OUTPUT_RAW" - -RAW_SIZE=$(stat -c%s "$OUTPUT_RAW") -echo "" -echo "=== Build complete ===" -echo " qcow2: $OUTPUT_IMAGE ($(du -h "$OUTPUT_IMAGE" | cut -f1))" -echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))" -echo "" - -if [ "$DO_IMPORT" = true ]; then - echo "Importing as AMI..." - (cd "$PROJECT_DIR" && ./bin/spx admin images import \ - --file "$OUTPUT_RAW" \ - --distro alpine \ - --version "${ALPINE_VERSION}-alb" \ - --arch x86_64 \ - --config "$HOME/spinifex/config/spinifex.toml") -else - echo "To import as AMI, run:" - echo " cd $PROJECT_DIR && ./bin/spx admin images import \\" - echo " --file $OUTPUT_RAW \\" - echo " --distro alpine --version ${ALPINE_VERSION}-alb --arch x86_64 \\" - echo " --config \$HOME/spinifex/config/spinifex.toml" -fi +# build-alb-image.sh — Thin wrapper for backward compatibility +# Delegates to the generic build-system-image.sh with the ALB manifest. +exec "$(dirname "$0")/build-system-image.sh" "$(dirname "$0")/images/alb.conf" "$@" diff --git a/scripts/build-system-image.sh b/scripts/build-system-image.sh new file mode 100755 index 000000000..724ce559d --- /dev/null +++ b/scripts/build-system-image.sh @@ -0,0 +1,275 @@ +#!/bin/bash +set -euo pipefail + +# build-system-image.sh — Build a minimal Alpine AMI from a manifest +# +# Creates a pre-baked Alpine Linux image with custom packages, binaries, +# and setup scripts installed, ready for import as a Spinifex AMI. +# +# Requirements: qemu-nbd, qemu-img, sudo (for mount/chroot), curl +# Usage: ./scripts/build-system-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)" + +usage() { + echo "Usage: $0 [--import]" + echo "" + echo " manifest.conf Path to image manifest (see scripts/images/ for examples)" + echo " --import Import the built image as an AMI" + exit 1 +} + +if [[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]]; then + usage +fi + +MANIFEST="$1" +shift + +if [[ ! -f "$MANIFEST" ]]; then + echo "ERROR: Manifest not found: $MANIFEST" + exit 1 +fi + +DO_IMPORT=false +if [[ "${1:-}" == "--import" ]]; then + DO_IMPORT=true +fi + +# Source the manifest +# shellcheck source=/dev/null +source "$MANIFEST" + +# Validate required manifest fields +for field in IMAGE_NAME ALPINE_VERSION IMAGE_SIZE; do + if [[ -z "${!field:-}" ]]; then + echo "ERROR: Manifest missing required field: $field" + exit 1 + fi +done + +# Derived paths +BUILD_DIR="/tmp/${IMAGE_NAME}-image-build" +ALPINE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2" +ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION%.*}/releases/cloud/${ALPINE_IMAGE}" +OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-alpine.qcow2" +OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-alpine.raw" +NBD_DEV="/dev/nbd0" +MOUNT_DIR="${BUILD_DIR}/mnt" + +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 "=== System Image Builder ===" +echo "Image: ${IMAGE_NAME} — ${IMAGE_DESCRIPTION:-}" +echo "Alpine: ${ALPINE_VERSION}" +echo "Size: ${IMAGE_SIZE}" +echo "Build: ${BUILD_DIR}" +echo "" + +# Step 0: Check prerequisites +if ! command -v qemu-nbd &>/dev/null; then + echo "ERROR: qemu-nbd not found. Install qemu-utils." + exit 1 +fi + +if ! command -v qemu-img &>/dev/null; then + echo "ERROR: qemu-img not found. Install qemu-utils." + exit 1 +fi + +# Build binaries if BUILD_COMMANDS is set +if [[ -n "${BUILD_COMMANDS:-}" ]]; then + echo "Building binaries: ${BUILD_COMMANDS}" + if ! (cd "$PROJECT_DIR" && eval "$BUILD_COMMANDS"); then + echo "ERROR: BUILD_COMMANDS failed: ${BUILD_COMMANDS}" + exit 1 + fi +fi + +# Verify binaries exist and are statically linked +if [[ -n "${INSTALL_BINARIES:-}" ]]; then + IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES" + for pair in "${BINARY_PAIRS[@]}"; do + src="${pair%%:*}" + src_path="${PROJECT_DIR}/${src}" + if [[ ! -f "$src_path" ]]; then + echo "ERROR: Binary not found: $src_path" + exit 1 + fi + if ! file "$src_path" | grep -q "statically linked"; then + echo "ERROR: $src_path is not statically linked (Alpine uses musl — dynamic glibc binaries will fail)" + echo " Rebuild with: CGO_ENABLED=0 go build ..." + exit 1 + fi + done +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..." + if ! curl --fail -L -o "${BUILD_DIR}/${ALPINE_IMAGE}" "$ALPINE_URL"; then + rm -f "${BUILD_DIR}/${ALPINE_IMAGE}" + echo "ERROR: Failed to download Alpine image from $ALPINE_URL" + exit 1 + fi + # Verify the download is a valid qcow2 image + if ! qemu-img info "${BUILD_DIR}/${ALPINE_IMAGE}" &>/dev/null; then + rm -f "${BUILD_DIR}/${ALPINE_IMAGE}" + echo "ERROR: Downloaded file is not a valid qcow2 image" + exit 1 + fi +fi + +# Step 2: Copy image for customization +echo "Copying image for customization..." +cp "${BUILD_DIR}/${ALPINE_IMAGE}" "$OUTPUT_IMAGE" + +# Resize the image (Alpine cloud images are ~200MB, need room for packages) +qemu-img resize "$OUTPUT_IMAGE" "$IMAGE_SIZE" + +# Step 3: Connect via qemu-nbd +echo "Connecting image via qemu-nbd..." +sudo modprobe nbd max_part=4 2>/dev/null || true +if [[ ! -e "${NBD_DEV}" ]]; then + echo "ERROR: ${NBD_DEV} does not exist. Is the nbd kernel module loaded? Try: sudo modprobe nbd" + exit 1 +fi +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 "Checking filesystem..." +sudo e2fsck -f -y "${NBD_DEV}" || { + ec=$? + if [[ $ec -gt 1 ]]; then + echo "ERROR: e2fsck failed with exit code $ec on ${NBD_DEV}" + exit 1 + fi +} + +echo "Resizing filesystem..." +if ! sudo resize2fs "${NBD_DEV}"; then + echo "ERROR: resize2fs failed on ${NBD_DEV}" + exit 1 +fi + +# 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" + +# Install packages +if [[ -n "${APK_PACKAGES:-}" ]]; then + echo "Installing packages: ${APK_PACKAGES}..." + sudo chroot "$MOUNT_DIR" /bin/sh -c " +set -e +# Enable community repo +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 ${APK_PACKAGES} +" +fi + +# Enable OpenRC services +if [[ -n "${ENABLE_SERVICES:-}" ]]; then + echo "Enabling services: ${ENABLE_SERVICES}..." + IFS=' ' read -ra SERVICES <<< "$ENABLE_SERVICES" + for svc in "${SERVICES[@]}"; do + if ! sudo chroot "$MOUNT_DIR" /bin/sh -c "rc-update add ${svc} default"; then + echo "ERROR: Failed to enable service '${svc}' — does it exist in the image?" + exit 1 + fi + done +fi + +# Step 5: Copy binaries into the image (before setup script, which may reference them) +if [[ -n "${INSTALL_BINARIES:-}" ]]; then + echo "Installing binaries..." + IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES" + for pair in "${BINARY_PAIRS[@]}"; do + src="${pair%%:*}" + dst="${pair#*:}" + src_path="${PROJECT_DIR}/${src}" + echo " ${src} -> ${dst}" + sudo cp "$src_path" "${MOUNT_DIR}${dst}" + sudo chmod 755 "${MOUNT_DIR}${dst}" + done +fi + +# Run custom setup script inside chroot (after binaries are installed) +if [[ -n "${SETUP_SCRIPT:-}" ]]; then + setup_path="${PROJECT_DIR}/${SETUP_SCRIPT}" + if [[ ! -f "$setup_path" ]]; then + echo "ERROR: Setup script not found: $setup_path" + exit 1 + fi + echo "Running setup script: ${SETUP_SCRIPT}..." + sudo cp "$setup_path" "${MOUNT_DIR}/tmp/setup.sh" + sudo chmod 755 "${MOUNT_DIR}/tmp/setup.sh" + sudo chroot "$MOUNT_DIR" /tmp/setup.sh + sudo rm -f "${MOUNT_DIR}/tmp/setup.sh" +fi + +# 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" + +echo "" +echo "=== Build complete ===" +echo " Image: ${IMAGE_NAME}" +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}-${IMAGE_NAME}" \ + --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}-${IMAGE_NAME} --arch x86_64 \\" + echo " --config \$HOME/spinifex/config/spinifex.toml" +fi diff --git a/scripts/images/alb-setup.sh b/scripts/images/alb-setup.sh new file mode 100755 index 000000000..1b986e73b --- /dev/null +++ b/scripts/images/alb-setup.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# alb-setup.sh — ALB-specific chroot setup +# +# Runs inside the Alpine image chroot after packages are installed. +# Sets up HAProxy placeholder config and alb-agent OpenRC init script. + +set -e + +# Create haproxy config directory and placeholder config +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 OpenRC init script +mkdir -p /etc/init.d +cat > /etc/init.d/alb-agent <<'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 +chmod 755 /etc/init.d/alb-agent diff --git a/scripts/images/alb.conf b/scripts/images/alb.conf new file mode 100644 index 000000000..31675c23f --- /dev/null +++ b/scripts/images/alb.conf @@ -0,0 +1,22 @@ +# ALB system image manifest +# Used by build-system-image.sh to create an Alpine AMI with HAProxy + alb-agent + +IMAGE_NAME="alb" +IMAGE_DESCRIPTION="ALB VM with HAProxy and alb-agent" +ALPINE_VERSION="3.21.6" +IMAGE_SIZE="512M" + +# Packages to install via apk +APK_PACKAGES="haproxy curl" + +# Binaries to copy into the image (source:destination) +INSTALL_BINARIES="bin/alb-agent:/usr/local/bin/alb-agent" + +# Build command for binaries (run before image build) +BUILD_COMMANDS="make build-alb-agent" + +# OpenRC services to enable at boot +ENABLE_SERVICES="haproxy" + +# Custom setup script (runs inside chroot after packages are installed) +SETUP_SCRIPT="scripts/images/alb-setup.sh" diff --git a/spinifex/albagent/agent.go b/spinifex/albagent/agent.go index 3dcaa8f3e..34572f229 100644 --- a/spinifex/albagent/agent.go +++ b/spinifex/albagent/agent.go @@ -187,7 +187,11 @@ func (a *Agent) handlePing(msg *nats.Msg) { ConfigAge: configAge, } - data, _ := json.Marshal(resp) + data, err := json.Marshal(resp) + if err != nil { + slog.Error("Failed to marshal ping response", "err", err) + return + } if err := msg.Respond(data); err != nil { slog.Warn("Failed to respond to ping", "err", err) } @@ -202,7 +206,11 @@ func (a *Agent) respond(msg *nats.Msg, status, errMsg string) { if errMsg != "" { resp["error"] = errMsg } - data, _ := json.Marshal(resp) + data, err := json.Marshal(resp) + if err != nil { + slog.Error("Failed to marshal response", "err", err) + return + } if err := msg.Respond(data); err != nil { slog.Warn("Failed to respond", "err", err) } @@ -212,12 +220,12 @@ func (a *Agent) respond(msg *nats.Msg, status, errMsg string) { // 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 { + if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("create config dir: %w", err) } tmp := path + ".tmp" - if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil { return fmt.Errorf("write temp config: %w", err) } if err := os.Rename(tmp, path); err != nil { @@ -233,7 +241,7 @@ func WriteConfig(path, content string) error { 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) + _ = os.MkdirAll("/tmp/spinifex-haproxy", 0o750) oldPID := readPID(pidPath) diff --git a/spinifex/awsec2query/parser_test.go b/spinifex/awsec2query/parser_test.go index b37a9ac91..df9795012 100644 --- a/spinifex/awsec2query/parser_test.go +++ b/spinifex/awsec2query/parser_test.go @@ -517,7 +517,7 @@ 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", + "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", } @@ -535,7 +535,7 @@ func TestQueryParamsToStruct_ELBv2RegisterTargets(t *testing.T) { 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", + "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123", "Targets.member.1.Id": "i-1234567890abcdef0", "Targets.member.1.Port": "8080", } @@ -570,12 +570,12 @@ func TestQueryParamsToStruct_ELBv2CreateLoadBalancerMemberSubnets(t *testing.T) 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", + "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{} diff --git a/spinifex/awserrors/awserrors.go b/spinifex/awserrors/awserrors.go index 78847bfd1..5aaec4950 100644 --- a/spinifex/awserrors/awserrors.go +++ b/spinifex/awserrors/awserrors.go @@ -434,21 +434,21 @@ var ( 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" + 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" ) @@ -890,20 +890,20 @@ var ErrorLookup = map[string]ErrorMessage{ 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."}, + ErrorELBv2LoadBalancerNotFound: {HTTPCode: 400, Message: "One or more load balancers not found."}, + ErrorELBv2TargetGroupNotFound: {HTTPCode: 400, Message: "One or more target groups not found."}, + ErrorELBv2ListenerNotFound: {HTTPCode: 400, Message: "One or more listeners not found."}, + ErrorELBv2DuplicateLoadBalancer: {HTTPCode: 400, Message: "A load balancer with the same name already exists."}, + ErrorELBv2DuplicateTargetGroup: {HTTPCode: 400, Message: "A target group with the same name already exists."}, + ErrorELBv2DuplicateListener: {HTTPCode: 400, Message: "A listener with the specified port already exists on this load balancer."}, + ErrorELBv2TooManyLoadBalancers: {HTTPCode: 400, Message: "You've reached the limit on the number of load balancers for your account."}, + ErrorELBv2TooManyTargetGroups: {HTTPCode: 400, Message: "You've reached the limit on the number of target groups for your account."}, + ErrorELBv2TooManyListeners: {HTTPCode: 400, Message: "You've reached the limit on the number of listeners per load balancer."}, + ErrorELBv2TooManyTargets: {HTTPCode: 400, Message: "You've reached the limit on the number of times a target can be registered with a target group."}, + ErrorELBv2InvalidTarget: {HTTPCode: 400, Message: "The specified target does not exist, is not in the same VPC as the target group, or has an unsupported instance type."}, + ErrorELBv2TargetGroupInUse: {HTTPCode: 400, Message: "The target group is currently in use by a listener or rule."}, + ErrorELBv2InvalidSecurityGroup: {HTTPCode: 400, Message: "The specified security group does not exist."}, + ErrorELBv2InvalidScheme: {HTTPCode: 400, Message: "The specified scheme is not valid. Specify 'internet-facing' or 'internal'."}, + ErrorELBv2SubnetNotFound: {HTTPCode: 400, Message: "The specified subnet does not exist."}, ErrorELBv2AvailabilityZoneNotSupported: {HTTPCode: 400, Message: "The specified Availability Zone is not supported."}, } diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 385cfe36b..d850ece27 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -47,10 +47,10 @@ type GatewayConfig struct { } var supportedServices = map[string]bool{ - "ec2": true, - "iam": true, - "account": true, - "elasticloadbalancing": true, + "ec2": true, + "iam": true, + "account": true, + "elasticloadbalancing": true, } type ErrorResponse struct { diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index 52cb910ca..12f32d61f 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -646,14 +646,14 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i slog.Warn("Failed to decode user-data, ignoring", "err", decErr) } else { raw := string(decoded) - if strings.HasPrefix(raw, "#cloud-config") { + if after, ok := strings.CutPrefix(raw, "#cloud-config"); ok { // Strip the #cloud-config header — the template already has it - stripped := strings.TrimPrefix(raw, "#cloud-config") + stripped := after 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") { + for line := range strings.SplitSeq(raw, "\n") { indented.WriteString(" " + line + "\n") } userData.UserDataScript = indented.String() diff --git a/spinifex/handlers/elbv2/haproxy.go b/spinifex/handlers/elbv2/haproxy.go index cf4c24b90..628ad3a21 100644 --- a/spinifex/handlers/elbv2/haproxy.go +++ b/spinifex/handlers/elbv2/haproxy.go @@ -357,7 +357,7 @@ func (m *HAProxyManager) WriteConfig(lbID, configContent string) (string, error) } path := filepath.Join(m.configDir, fmt.Sprintf("alb-%s.cfg", lbID)) - if err := os.WriteFile(path, []byte(configContent), 0o640); err != nil { + if err := os.WriteFile(path, []byte(configContent), 0o600); err != nil { return "", fmt.Errorf("write haproxy config: %w", err) } diff --git a/spinifex/handlers/elbv2/haproxy_test.go b/spinifex/handlers/elbv2/haproxy_test.go index 610430fd7..63485ccbe 100644 --- a/spinifex/handlers/elbv2/haproxy_test.go +++ b/spinifex/handlers/elbv2/haproxy_test.go @@ -136,7 +136,7 @@ func TestGenerateHAProxyConfig_SkipsDrainingTargets(t *testing.T) { 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 + assert.NotContains(t, config, "i-no-ip") // no IP } func TestGenerateHAProxyConfig_SharedTargetGroup(t *testing.T) { diff --git a/spinifex/handlers/elbv2/service_impl.go b/spinifex/handlers/elbv2/service_impl.go index 8538ce90f..fb95b6157 100644 --- a/spinifex/handlers/elbv2/service_impl.go +++ b/spinifex/handlers/elbv2/service_impl.go @@ -49,11 +49,11 @@ type ELBv2ServiceImpl struct { store *Store nc *nats.Conn // NATS connection for publishing config to ALB VMs vpcService *handlers_ec2_vpc.VPCServiceImpl // nil-safe: ENI ops skipped when nil (e.g. in tests) - instanceLauncher SystemInstanceLauncher // nil-safe: system VM ops skipped when nil + instanceLauncher SystemInstanceLauncher // nil-safe: system VM ops skipped when nil nodeID string region string - systemAMI string // AMI ID for system VMs (ALB VMs), set by daemon - natsURL string // NATS URL for ALB VM cloud-init, set by daemon + systemAMI string // AMI ID for system VMs (ALB VMs), set by daemon + natsURL string // NATS URL for ALB VM cloud-init, set by daemon } // NewELBv2ServiceImplWithNATS creates an ELBv2 service backed by JetStream KV. @@ -103,7 +103,6 @@ func (s *ELBv2ServiceImpl) SetNATSURL(url string) { s.natsURL = url } - // resolveENIBindAddr looks up the private IP of the first ENI belonging to the ALB. // Returns empty string if VPC service is unavailable or no ENIs exist. func (s *ELBv2ServiceImpl) resolveENIBindAddr(lb *LoadBalancerRecord) string { @@ -364,9 +363,11 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp if eniErr != nil { // Rollback: delete any ENIs already created for _, rollbackENI := range eniIDs { - s.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{ + if _, delErr := s.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{ NetworkInterfaceId: aws.String(rollbackENI), - }, accountID) + }, accountID); delErr != nil { + slog.Error("CreateLoadBalancer: rollback failed to delete ENI", "eni", rollbackENI, "err", delErr) + } } slog.Error("CreateLoadBalancer: failed to create ENI", "subnet", subnetID, "err", eniErr) return nil, errors.New(awserrors.ErrorELBv2SubnetNotFound) @@ -1055,14 +1056,14 @@ func (s *ELBv2ServiceImpl) DescribeListeners(input *elbv2.DescribeListenersInput func (s *ELBv2ServiceImpl) lbRecordToSDK(r *LoadBalancerRecord) *elbv2.LoadBalancer { lb := &elbv2.LoadBalancer{ - LoadBalancerArn: aws.String(r.LoadBalancerArn), + LoadBalancerArn: aws.String(r.LoadBalancerArn), LoadBalancerName: aws.String(r.Name), - DNSName: aws.String(r.DNSName), - Scheme: aws.String(r.Scheme), - Type: aws.String(r.Type), - IpAddressType: aws.String(r.IPAddressType), - CreatedTime: aws.Time(r.CreatedAt), - VpcId: aws.String(r.VpcId), + DNSName: aws.String(r.DNSName), + Scheme: aws.String(r.Scheme), + Type: aws.String(r.Type), + IpAddressType: aws.String(r.IPAddressType), + CreatedTime: aws.Time(r.CreatedAt), + VpcId: aws.String(r.VpcId), State: &elbv2.LoadBalancerState{ Code: aws.String(r.State), }, @@ -1084,19 +1085,19 @@ func (s *ELBv2ServiceImpl) lbRecordToSDK(r *LoadBalancerRecord) *elbv2.LoadBalan func (s *ELBv2ServiceImpl) tgRecordToSDK(r *TargetGroupRecord) *elbv2.TargetGroup { return &elbv2.TargetGroup{ - TargetGroupArn: aws.String(r.TargetGroupArn), - TargetGroupName: aws.String(r.Name), - Protocol: aws.String(r.Protocol), - Port: aws.Int64(r.Port), - VpcId: aws.String(r.VpcId), - TargetType: aws.String(r.TargetType), - HealthCheckProtocol: aws.String(r.HealthCheck.Protocol), - HealthCheckPort: aws.String(r.HealthCheck.Port), - HealthCheckPath: aws.String(r.HealthCheck.Path), + TargetGroupArn: aws.String(r.TargetGroupArn), + TargetGroupName: aws.String(r.Name), + Protocol: aws.String(r.Protocol), + Port: aws.Int64(r.Port), + VpcId: aws.String(r.VpcId), + TargetType: aws.String(r.TargetType), + HealthCheckProtocol: aws.String(r.HealthCheck.Protocol), + HealthCheckPort: aws.String(r.HealthCheck.Port), + HealthCheckPath: aws.String(r.HealthCheck.Path), HealthCheckIntervalSeconds: aws.Int64(r.HealthCheck.IntervalSeconds), HealthCheckTimeoutSeconds: aws.Int64(r.HealthCheck.TimeoutSeconds), - HealthyThresholdCount: aws.Int64(r.HealthCheck.HealthyThreshold), - UnhealthyThresholdCount: aws.Int64(r.HealthCheck.UnhealthyThreshold), + HealthyThresholdCount: aws.Int64(r.HealthCheck.HealthyThreshold), + UnhealthyThresholdCount: aws.Int64(r.HealthCheck.UnhealthyThreshold), Matcher: &elbv2.Matcher{ HttpCode: aws.String(r.HealthCheck.Matcher), }, diff --git a/spinifex/handlers/elbv2/service_impl_test.go b/spinifex/handlers/elbv2/service_impl_test.go index b275d8232..f36ec44d1 100644 --- a/spinifex/handlers/elbv2/service_impl_test.go +++ b/spinifex/handlers/elbv2/service_impl_test.go @@ -43,8 +43,8 @@ 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")}, + Name: aws.String("my-alb"), + Subnets: []*string{aws.String("subnet-aaa")}, SecurityGroups: []*string{aws.String("sg-111")}, }, testAccountID) @@ -640,4 +640,3 @@ func (m *mockTerminateLauncher) TerminateSystemInstance(instanceID string) error m.terminateCalls = append(m.terminateCalls, instanceID) return nil } - diff --git a/spinifex/handlers/elbv2/types.go b/spinifex/handlers/elbv2/types.go index 674cfc607..8b45cd909 100644 --- a/spinifex/handlers/elbv2/types.go +++ b/spinifex/handlers/elbv2/types.go @@ -50,18 +50,18 @@ type LoadBalancerRecord struct { 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" + 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) + 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" + 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"` @@ -78,10 +78,10 @@ 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 + 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 + TargetType string `json:"target_type"` // "instance" for v1 HealthCheck HealthCheckConfig `json:"health_check"` Targets []Target `json:"targets"` Tags map[string]string `json:"tags,omitempty"` @@ -91,14 +91,14 @@ type TargetGroupRecord struct { // 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" + 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. @@ -138,6 +138,6 @@ type ListenerRecord struct { // ListenerAction defines a listener's default action. type ListenerAction struct { - Type string `json:"type"` // "forward" + Type string `json:"type"` // "forward" TargetGroupArn string `json:"target_group_arn"` }