main
1#!/usr/bin/env bash
2# Test an Alpine appliance image in QEMU.
3# Usage: ./alpine/test.sh <hostname> [stop|validate|build-qemu]
4set -euo pipefail
5
6SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7HOST="${1:?Usage: $0 <hostname> [stop|validate]}"
8ACTION="${2:-start}"
9IMAGE_DIR="$SCRIPT_DIR/images/$HOST"
10QEMU_DIR="$IMAGE_DIR/qemu"
11MONITOR_SOCK="/tmp/qemu-alpine-${HOST}.sock"
12SSH_PORT="${QEMU_SSH_PORT:-2222}"
13CONTAINER_ENGINE="${CONTAINER_ENGINE:-podman}"
14
15if [ "$ACTION" = "stop" ]; then
16 if [ -S "$MONITOR_SOCK" ]; then
17 echo "quit" | socat - UNIX-CONNECT:"$MONITOR_SOCK" 2>/dev/null
18 echo "==> QEMU for $HOST stopped"
19 else
20 echo "No running QEMU found for $HOST, trying pkill..."
21 pkill -f "qemu.*alpine-${HOST}" 2>/dev/null || echo "Nothing to kill"
22 fi
23 exit 0
24fi
25
26if [ "$ACTION" = "validate" ]; then
27 echo "==> Validating $HOST (via SSH on localhost:$SSH_PORT)"
28 echo ""
29 SSH_CMD="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $SSH_PORT root@localhost"
30
31 run_check() {
32 local desc="$1"
33 shift
34 printf " %-45s" "$desc"
35 if output=$($SSH_CMD "$@" 2>/dev/null); then
36 echo "✓"
37 # shellcheck disable=SC2001
38 [ -n "${VERBOSE:-}" ] && echo "$output" | sed 's/^/ /'
39 return 0
40 else
41 echo "✗"
42 # shellcheck disable=SC2001
43 [ -n "${VERBOSE:-}" ] && echo "$output" | sed 's/^/ /'
44 return 1
45 fi
46 }
47
48 FAILURES=0
49
50 # Basic system checks
51 run_check "SSH access" "echo ok" || FAILURES=$((FAILURES + 1))
52 run_check "Hostname is $HOST" "test \$(hostname) = $HOST" || FAILURES=$((FAILURES + 1))
53 run_check "OpenRC running" "rc-status --nocolor" || FAILURES=$((FAILURES + 1))
54
55 # Service checks
56 run_check "sshd running" "rc-service sshd status" || FAILURES=$((FAILURES + 1))
57 run_check "chronyd running" "rc-service chronyd status" || FAILURES=$((FAILURES + 1))
58 run_check "crond running" "rc-service crond status" || FAILURES=$((FAILURES + 1))
59 run_check "node-exporter running" "rc-service node-exporter status" || FAILURES=$((FAILURES + 1))
60
61 # Host-specific checks
62 case "$HOST" in
63 athena|demeter)
64 run_check "named running" "rc-service named status" || FAILURES=$((FAILURES + 1))
65 run_check "BIND config valid" "named-checkconf /etc/bind/named.conf" || FAILURES=$((FAILURES + 1))
66 if [ -d "$SCRIPT_DIR/generated/zones" ]; then
67 run_check "DNS zones present" "ls /etc/bind/zones/db.* >/dev/null" || FAILURES=$((FAILURES + 1))
68 run_check "DNS resolves 'home'" "dig @127.0.0.1 home SOA +short" || FAILURES=$((FAILURES + 1))
69 else
70 echo " DNS zones not generated (skipping zone checks)"
71 fi
72 ;;&
73 demeter)
74 run_check "mosquitto running" "rc-service mosquitto status" || FAILURES=$((FAILURES + 1))
75 ;;
76 aix)
77 run_check "samba running" "rc-service samba status" || FAILURES=$((FAILURES + 1))
78 run_check "dnsmasq running" "rc-service dnsmasq status" || FAILURES=$((FAILURES + 1))
79 ;;
80 esac
81
82 # Secrets init script (will fail without persist partition, that's OK)
83 run_check "secrets init script exists" "test -x /etc/init.d/secrets" || FAILURES=$((FAILURES + 1))
84
85 # Auth
86 run_check "root authorized_keys exists" "test -s /root/.ssh/authorized_keys" || FAILURES=$((FAILURES + 1))
87 run_check "vincent authorized_keys exists" "test -s /home/vincent/.ssh/authorized_keys" || FAILURES=$((FAILURES + 1))
88
89 # Network config
90 run_check "WireGuard config exists" "test -f /etc/wireguard/wg0.conf" || FAILURES=$((FAILURES + 1))
91
92 # Prometheus endpoints
93 run_check "node-exporter responds" "wget -qO- http://127.0.0.1:9100/metrics | head -1" || FAILURES=$((FAILURES + 1))
94
95 echo ""
96 if [ "$FAILURES" -eq 0 ]; then
97 echo " ✓ All checks passed!"
98 else
99 echo " ✗ $FAILURES check(s) failed"
100 fi
101 exit "$FAILURES"
102fi
103
104# --- Build QEMU-specific initramfs ---
105# The production image uses linux-rpi (no virtio drivers).
106# For QEMU we need linux-virt + DHCP networking.
107build_qemu_image() {
108 echo "==> Building QEMU test image for $HOST..."
109 mkdir -p "$QEMU_DIR"
110
111 # Resolve Nix store symlinks
112 GENERATED_MOUNTS=""
113 if [ -L "$SCRIPT_DIR/generated/zones" ]; then
114 REAL_ZONES=$(readlink -f "$SCRIPT_DIR/generated/zones")
115 GENERATED_MOUNTS="$GENERATED_MOUNTS -v $REAL_ZONES:$REAL_ZONES:ro"
116 fi
117 if [ -L "$SCRIPT_DIR/generated/wireguard" ]; then
118 REAL_WG=$(readlink -f "$SCRIPT_DIR/generated/wireguard")
119 GENERATED_MOUNTS="$GENERATED_MOUNTS -v $REAL_WG:$REAL_WG:ro"
120 fi
121
122 # shellcheck disable=SC2086,SC2016
123 $CONTAINER_ENGINE run --rm \
124 --platform linux/arm64 \
125 -v "$SCRIPT_DIR":/build:Z \
126 $GENERATED_MOUNTS \
127 -e HOST="$HOST" \
128 -w /tmp \
129 docker.io/library/alpine:3.21 \
130 sh -c '
131set -e
132HOST="$1"
133BUILD_DIR="/build"
134COMMON_DIR="$BUILD_DIR/common"
135HOST_DIR="$BUILD_DIR/hosts/$HOST"
136QEMU_DIR="$BUILD_DIR/images/$HOST/qemu"
137
138apk add --no-cache cpio gzip findutils >/dev/null
139
140ROOTFS=$(mktemp -d)
141
142# Copy skeletons (same as production)
143[ -d "$COMMON_DIR/root" ] && cp -a "$COMMON_DIR/root/." "$ROOTFS/"
144[ -d "$HOST_DIR/root" ] && cp -a "$HOST_DIR/root/." "$ROOTFS/"
145
146# DNS zones
147if [ -d "$BUILD_DIR/generated/zones" ]; then
148 mkdir -p "$ROOTFS/etc/bind/zones"
149 cp -aL "$BUILD_DIR/generated/zones/." "$ROOTFS/etc/bind/zones/"
150fi
151
152# Override network config for QEMU (DHCP)
153cat > "$ROOTFS/etc/network/interfaces" << IFACES
154auto lo
155iface lo inet loopback
156
157auto eth0
158iface eth0 inet dhcp
159IFACES
160
161# Merge packages — replace linux-rpi with linux-virt
162PACKAGES=""
163[ -f "$COMMON_DIR/packages" ] && PACKAGES="$(grep -v "^#" "$COMMON_DIR/packages" | grep -v "^$" | grep -v "linux-rpi" | tr "\n" " ")"
164[ -f "$HOST_DIR/packages" ] && PACKAGES="$PACKAGES $(grep -v "^#" "$HOST_DIR/packages" | grep -v "^$" | tr "\n" " ")"
165PACKAGES="$PACKAGES linux-virt"
166
167# Install
168mkdir -p "$ROOTFS/etc/apk" "$ROOTFS/etc/apk/keys"
169echo "https://dl-cdn.alpinelinux.org/alpine/v3.21/main" > "$ROOTFS/etc/apk/repositories"
170echo "https://dl-cdn.alpinelinux.org/alpine/v3.21/community" >> "$ROOTFS/etc/apk/repositories"
171cp /etc/apk/keys/* "$ROOTFS/etc/apk/keys/"
172apk add --root "$ROOTFS" --initdb --no-cache $PACKAGES
173
174# Run setup
175SETUP=$(mktemp)
176echo "#!/bin/sh" > "$SETUP"
177echo "set -e" >> "$SETUP"
178[ -f "$COMMON_DIR/setup.sh" ] && cat "$COMMON_DIR/setup.sh" >> "$SETUP"
179[ -f "$HOST_DIR/setup.sh" ] && cat "$HOST_DIR/setup.sh" >> "$SETUP"
180chmod +x "$SETUP"
181cp "$SETUP" "$ROOTFS/tmp/setup.sh"
182find "$ROOTFS/etc/init.d" -type f -exec chmod +x {} \; 2>/dev/null || true
183chroot "$ROOTFS" /tmp/setup.sh
184rm -f "$ROOTFS/tmp/setup.sh"
185
186# Extract kernel
187cp "$ROOTFS/boot/vmlinuz-virt" "$QEMU_DIR/"
188rm -rf "$ROOTFS/boot" "$ROOTFS/usr/share/u-boot"
189
190# Create initramfs
191cd "$ROOTFS"
192find . -print | cpio -o -H newc | gzip > "$QEMU_DIR/initramfs"
193
194echo "QEMU image ready"
195ls -lh "$QEMU_DIR/"
196' -- "$HOST"
197}
198
199# --- Start QEMU ---
200if [ ! -f "$QEMU_DIR/initramfs" ] || [ ! -f "$QEMU_DIR/vmlinuz-virt" ]; then
201 build_qemu_image
202fi
203
204# Rebuild if production image is newer
205if [ "$IMAGE_DIR/boot/initramfs" -nt "$QEMU_DIR/initramfs" ] 2>/dev/null; then
206 echo "==> Production image is newer, rebuilding QEMU image..."
207 build_qemu_image
208fi
209
210echo "==> Testing $HOST in QEMU"
211echo " SSH: ssh -p $SSH_PORT -o StrictHostKeyChecking=no root@localhost"
212echo " Console: root / alpine"
213echo " Stop: ./alpine/test.sh $HOST stop"
214echo " Validate: ./alpine/test.sh $HOST validate (in another terminal)"
215echo ""
216qemu-system-aarch64 -M virt -cpu cortex-a72 -m 1G -nographic \
217 -monitor unix:"$MONITOR_SOCK",server,nowait \
218 -kernel "$QEMU_DIR/vmlinuz-virt" \
219 -initrd "$QEMU_DIR/initramfs" \
220 -netdev user,id=net0,hostfwd=tcp::"$SSH_PORT"-:22 \
221 -device virtio-net-pci,netdev=net0 \
222 -append "console=ttyAMA0 rdinit=/sbin/init"