feat: generic system image builder + lint fixes (mulga-766)

Extract build-alb-image.sh into manifest-driven build-system-image.sh:
- alb.conf manifest + alb-setup.sh for ALB-specific config
- build-alb-image.sh kept as thin wrapper for backward compat
- Makefile targets: build-system-image, build-alb-image
- Hardened error handling: curl --fail, e2fsck/resize2fs checks,
  static-link enforcement, rc-update validation, nbd device guard

Also fixes 12 lint issues: errcheck, errchkjson, gosec, gofmt, modernize.
This commit is contained in:
joshsiv-mulga
2026-03-26 22:30:19 +10:00
parent f744aedede
commit 3129c9eb70
15 changed files with 462 additions and 304 deletions
+10 -1
View File
@@ -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
+3 -205
View File
@@ -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 <<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
# 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" "$@"
+275
View File
@@ -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 <manifest.conf> [--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 <manifest.conf> [--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
+46
View File
@@ -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
+22
View File
@@ -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"
+13 -5
View File
@@ -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)
+8 -8
View File
@@ -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{}
+30 -30
View File
@@ -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."},
}
+4 -4
View File
@@ -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 {
@@ -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()
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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) {
+25 -24
View File
@@ -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),
},
+2 -3
View File
@@ -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
}
+19 -19
View File
@@ -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"`
}