Files
stack/hw/build.sh
kert 59eb56f659
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 40s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bib) (push) Successful in 38s
CI / skinny-install (cli) (push) Successful in 46s
CI / skinny-install (conf) (push) Successful in 36s
CI / skinny-install (opps) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 47s
CI / skinny-install (rex) (push) Successful in 35s
Infra CI / notebooks (push) Successful in 3m17s
CI / lint-test (push) Failing after 3m30s
CI / skinny-install (bls) (push) Successful in 34s
CI / skinny-install (ccw) (push) Successful in 45s
CI / skinny-install (cms) (push) Successful in 32s
CI / skinny-install (perf) (push) Successful in 43s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Failing after 20s
Infra CI / api (push) Successful in 16s
Infra CI / mc (push) Successful in 12s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m27s
Infra CI / zotero (push) Successful in 6m10s
chore: hw provisioning, test coverage, deps
2026-04-09 22:26:31 -04:00

379 lines
11 KiB
Bash
Executable File

#!/bin/bash
#
# build.sh — Build bootable Alpine USB from config.yaml
#
# Reads config.yaml, builds a custom Alpine ISO with SSH keys baked in,
# and optionally flashes it to a USB drive.
#
# Usage:
# ./build.sh # build ISO only
# ./build.sh flash /dev/sdX # build + flash to USB
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG="${SCRIPT_DIR}/config.yaml"
WORKDIR="${SCRIPT_DIR}/build"
OUTDIR="${SCRIPT_DIR}/out"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[!]${NC} $*"; exit 1; }
[ -f "$CONFIG" ] || err "config.yaml not found at ${CONFIG}"
# -------------------------------------------------------------------
# Parse config.yaml with Python (pyyaml)
# -------------------------------------------------------------------
parse_yaml() {
python3 - "$CONFIG" "$@" << 'PYEOF'
import sys, yaml
with open(sys.argv[1]) as f:
cfg = yaml.safe_load(f)
query = sys.argv[2] if len(sys.argv) > 2 else None
def resolve(obj, path):
for key in path.split('.'):
if obj is None:
return ''
if isinstance(obj, list):
try:
obj = obj[int(key)]
except (ValueError, IndexError):
return ''
elif isinstance(obj, dict):
obj = obj.get(key)
else:
return ''
if isinstance(obj, list):
print('\n'.join(str(x) for x in obj))
elif isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, list):
print('\n'.join(str(x) for x in v))
else:
print(v)
elif obj is None:
pass
else:
print(obj)
if query:
resolve(cfg, query)
else:
yaml.dump(cfg, sys.stdout, default_flow_style=False)
PYEOF
}
# Read config values
ALPINE_VERSION=$(parse_yaml alpine.version)
ARCH=$(parse_yaml alpine.arch)
MIRROR=$(parse_yaml alpine.mirror)
HOSTNAME=$(parse_yaml host.name)
TIMEZONE=$(parse_yaml host.timezone)
KEYMAP=$(parse_yaml host.keymap)
NET_MODE=$(parse_yaml network.mode)
NET_IFACE=$(parse_yaml network.interface)
SSH_PORT=$(parse_yaml ssh.port)
MAIN_REPO="${MIRROR}/v${ALPINE_VERSION}/main"
COMMUNITY_REPO="${MIRROR}/v${ALPINE_VERSION}/community"
log "Building Alpine ${ALPINE_VERSION} (${ARCH}) ISO for '${HOSTNAME}'"
log "Network: ${NET_MODE} on ${NET_IFACE}"
log "SSH port: ${SSH_PORT}"
# -------------------------------------------------------------------
# Collect all packages from config
# -------------------------------------------------------------------
ALL_PACKAGES=$(parse_yaml packages)
PACKAGE_LIST=$(echo "$ALL_PACKAGES" | sort -u | tr '\n' ' ')
log "Packages: $(echo "$ALL_PACKAGES" | wc -l) total"
# -------------------------------------------------------------------
# Collect SSH authorized keys
# -------------------------------------------------------------------
AUTH_KEYS=$(parse_yaml ssh.authorized_keys)
KEY_COUNT=$(echo "$AUTH_KEYS" | grep -c "^ssh-\|^ecdsa-\|^sk-" || echo 0)
[ "$KEY_COUNT" -ge 1 ] || err "No SSH keys found in config.yaml ssh.authorized_keys"
log "SSH keys: ${KEY_COUNT}"
# -------------------------------------------------------------------
# Clean + setup
# -------------------------------------------------------------------
rm -rf "${WORKDIR}"
mkdir -p "${WORKDIR}" "${OUTDIR}"
# -------------------------------------------------------------------
# Clone aports
# -------------------------------------------------------------------
if [ ! -d "${WORKDIR}/aports" ]; then
log "Cloning aports build infrastructure..."
git clone --depth 1 --branch "v${ALPINE_VERSION}" \
https://gitlab.alpinelinux.org/alpine/aports.git \
"${WORKDIR}/aports"
fi
# -------------------------------------------------------------------
# Write custom ISO profile
# -------------------------------------------------------------------
log "Writing ISO profile..."
cat > "${WORKDIR}/aports/scripts/mkimg.storagenode.sh" << PROFILE
profile_storagenode() {
title="Alpine Storage Node"
desc="Headless server — SSH ready"
profile_standard
arch="${ARCH}"
output_format="iso"
image_ext="iso"
kernel_flavors="lts"
apks="\$apks
${PACKAGE_LIST}
"
}
PROFILE
# -------------------------------------------------------------------
# Build the overlay (auto-configures on first boot)
# -------------------------------------------------------------------
log "Building boot overlay..."
OVERLAY="${WORKDIR}/aports/scripts/storagenode-overlay"
mkdir -p "${OVERLAY}/etc/ssh"
mkdir -p "${OVERLAY}/etc/network"
mkdir -p "${OVERLAY}/etc/local.d"
mkdir -p "${OVERLAY}/root/.ssh"
# --- Network ---
if [ "$NET_MODE" = "dhcp" ]; then
cat > "${OVERLAY}/etc/network/interfaces" << NETCFG
auto lo
iface lo inet loopback
auto ${NET_IFACE}
iface ${NET_IFACE} inet dhcp
NETCFG
else
NET_ADDR=$(parse_yaml network.address)
NET_GW=$(parse_yaml network.gateway)
cat > "${OVERLAY}/etc/network/interfaces" << NETCFG
auto lo
iface lo inet loopback
auto ${NET_IFACE}
iface ${NET_IFACE} inet static
address ${NET_ADDR}
gateway ${NET_GW}
NETCFG
fi
DNS_SERVERS=$(parse_yaml network.dns)
echo "$DNS_SERVERS" | while read -r ns; do
[ -n "$ns" ] && echo "nameserver ${ns}"
done > "${OVERLAY}/etc/resolv.conf"
# --- SSH authorized keys ---
echo "$AUTH_KEYS" > "${OVERLAY}/root/.ssh/authorized_keys"
chmod 700 "${OVERLAY}/root/.ssh"
chmod 600 "${OVERLAY}/root/.ssh/authorized_keys"
# --- SSH server config ---
PERMIT_ROOT=$(parse_yaml ssh.permit_root)
PASS_AUTH=$(parse_yaml ssh.password_auth)
if [ "$PERMIT_ROOT" = "True" ] || [ "$PERMIT_ROOT" = "true" ]; then
ROOT_LOGIN="prohibit-password"
else
ROOT_LOGIN="no"
fi
if [ "$PASS_AUTH" = "True" ] || [ "$PASS_AUTH" = "true" ]; then
PASS_CFG="yes"
else
PASS_CFG="no"
fi
cat > "${OVERLAY}/etc/ssh/sshd_config" << SSHD
Port ${SSH_PORT}
ListenAddress 0.0.0.0
Protocol 2
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
PubkeyAuthentication yes
PubkeyAcceptedKeyTypes sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519,ssh-rsa
PasswordAuthentication ${PASS_CFG}
PermitRootLogin ${ROOT_LOGIN}
PermitEmptyPasswords no
ChallengeResponseAuthentication no
UsePAM no
X11Forwarding no
PrintMotd yes
ClientAliveInterval 60
ClientAliveCountMax 3
MaxAuthTries 6
SSHD
# --- Auto-setup script (runs on first boot via local.d) ---
cat > "${OVERLAY}/etc/local.d/01-setup.start" << 'BOOT'
#!/bin/sh
#
# First-boot auto-setup: networking + SSH
# Runs via local.d on every boot (idempotent)
#
# Generate host keys if missing
[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
[ -f /etc/ssh/ssh_host_rsa_key ] || ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ""
# Enable and start services
rc-update add sshd default 2>/dev/null || true
rc-update add networking boot 2>/dev/null || true
rc-update add chronyd default 2>/dev/null || true
rc-update add local default 2>/dev/null || true
# Bring up networking if not already
rc-service networking start 2>/dev/null || true
rc-service sshd start 2>/dev/null || true
rc-service chronyd start 2>/dev/null || true
# Log IP for console viewers
echo ""
echo "=== SSH READY ==="
ip -4 addr show dev eth0 2>/dev/null | grep inet | awk '{print " ssh root@" $2}' | sed 's|/.*||'
echo "================="
BOOT
chmod +x "${OVERLAY}/etc/local.d/01-setup.start"
# --- Hostname ---
echo "${HOSTNAME}" > "${OVERLAY}/etc/hostname"
# --- Timezone ---
mkdir -p "${OVERLAY}/etc/zoneinfo"
echo "${TIMEZONE}" > "${OVERLAY}/etc/timezone"
# --- Auto-setup answer file (for setup-alpine if needed) ---
cat > "${OVERLAY}/auto-setup.conf" << ANSWERS
KEYMAPOPTS="${KEYMAP} ${KEYMAP}"
HOSTNAMEOPTS="-n ${HOSTNAME}"
INTERFACESOPTS="auto lo
iface lo inet loopback
auto ${NET_IFACE}
iface ${NET_IFACE} inet ${NET_MODE}
"
DNSOPTS="-n $(echo "$DNS_SERVERS" | head -2 | tr '\n' ' ')"
TIMEZONEOPTS="-z ${TIMEZONE}"
PROXYOPTS="none"
SSHDOPTS="-c openssh"
NTPOPTS="-c chrony"
DISKOPTS="none"
LBUOPTS="none"
APKCACHEOPTS="none"
ANSWERS
# --- MOTD ---
cat > "${OVERLAY}/etc/motd" << 'MOTD'
storagenode — Alpine Live USB
SSH is enabled. Run 'setup-alpine' for full install.
MOTD
# -------------------------------------------------------------------
# Inject overlay into the ISO profile
# -------------------------------------------------------------------
log "Injecting overlay into ISO profile..."
# Create the apkovl tarball that Alpine live boots will auto-extract
cd "${OVERLAY}"
tar czf "${WORKDIR}/aports/scripts/${HOSTNAME}.apkovl.tar.gz" \
--owner=root --group=root \
etc/ root/
cd "${SCRIPT_DIR}"
# Patch the profile to include the apkovl
cat >> "${WORKDIR}/aports/scripts/mkimg.storagenode.sh" << APKOVL
profile_storagenode_apkovl() {
arch="${ARCH}"
apkovl="${HOSTNAME}.apkovl.tar.gz"
}
APKOVL
# Also make the profile reference the apkovl
sed -i 's|profile_standard|profile_standard\n apkovl="../${HOSTNAME}.apkovl.tar.gz"|' \
"${WORKDIR}/aports/scripts/mkimg.storagenode.sh"
# -------------------------------------------------------------------
# Build the ISO
# -------------------------------------------------------------------
log "Building ISO (this takes a few minutes)..."
cd "${WORKDIR}/aports/scripts"
sh mkimage.sh \
--tag storagenode \
--outdir "${OUTDIR}" \
--arch "${ARCH}" \
--repository "${MAIN_REPO}" \
--repository "${COMMUNITY_REPO}" \
--profile storagenode
ISO_FILE=$(ls "${OUTDIR}"/alpine-storagenode-*.iso 2>/dev/null | head -1)
[ -f "$ISO_FILE" ] || err "ISO build failed — no output file"
log "ISO built: ${ISO_FILE}"
ls -lh "$ISO_FILE"
# -------------------------------------------------------------------
# Flash to USB if requested
# -------------------------------------------------------------------
if [ "${1:-}" = "flash" ]; then
USB_TARGET="${2:-}"
[ -n "$USB_TARGET" ] || err "Usage: $0 flash /dev/sdX"
[ -b "$USB_TARGET" ] || err "${USB_TARGET} is not a block device"
# Safety check: is it USB?
TRAN=$(lsblk -d -n -o TRAN "$USB_TARGET" 2>/dev/null || true)
if [ "$TRAN" != "usb" ]; then
warn "Device ${USB_TARGET} transport is '${TRAN}', not 'usb'"
echo -n "Are you SURE this is the target USB drive? [y/N] "
read -r confirm
[ "$confirm" = "y" ] || exit 1
fi
SIZE=$(lsblk -d -n -o SIZE "$USB_TARGET")
echo ""
echo "THIS WILL ERASE ${USB_TARGET} (${SIZE}) COMPLETELY."
echo -n "Continue? [y/N] "
read -r confirm
[ "$confirm" = "y" ] || exit 1
log "Flashing to ${USB_TARGET}..."
dd if="$ISO_FILE" of="$USB_TARGET" bs=4M status=progress conv=fsync
sync
log "Flash complete. Remove USB and boot from it."
log "SSH will be available immediately after boot on ${NET_IFACE} (${NET_MODE})."
fi
echo ""
log "=== DONE ==="
echo ""
echo "To flash manually:"
echo " dd if=${ISO_FILE} of=/dev/sdX bs=4M status=progress && sync"
echo ""
echo "After boot, SSH in with:"
echo " ssh root@<ip>"
echo ""