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
379 lines
11 KiB
Bash
Executable File
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 ""
|