#!/usr/bin/env bash set -euo pipefail log() { printf '[make-rootfs-alpine] %s\n' "$*" } usage() { cat <<'EOF' Usage: ./scripts/make-rootfs-alpine.sh [--out ] [--size ] [--release ] [--mirror ] [--arch ] Build an experimental Alpine Linux rootfs image plus a matching /root work-seed. Defaults: --out ./build/manual/rootfs-alpine.ext4 --size 2G --release 3.23.3 --mirror https://dl-cdn.alpinelinux.org/alpine --arch x86_64 This path is experimental and local-only. If ./build/manual/alpine-kernel exists it uses the staged Alpine kernel modules from that directory. It does not change the default Debian image flow. EOF } parse_size() { local raw="$1" if [[ "$raw" =~ ^([0-9]+)([KMG])?$ ]]; then local num="${BASH_REMATCH[1]}" local unit="${BASH_REMATCH[2]}" case "$unit" in K) printf '%s\n' $((num * 1024)) ;; M|"") printf '%s\n' $((num * 1024 * 1024)) ;; G) printf '%s\n' $((num * 1024 * 1024 * 1024)) ;; esac return 0 fi return 1 } require_command() { local name="$1" command -v "$name" >/dev/null 2>&1 || { log "required command not found: $name" exit 1 } } resolve_banger_bin() { if [[ -n "${BANGER_BIN:-}" ]]; then printf '%s\n' "$BANGER_BIN" return fi if [[ -x "$REPO_ROOT/build/bin/banger" ]]; then printf '%s\n' "$REPO_ROOT/build/bin/banger" return fi if [[ -x "$REPO_ROOT/banger" ]]; then printf '%s\n' "$REPO_ROOT/banger" return fi if command -v banger >/dev/null 2>&1; then command -v banger return fi log "banger binary not found; build it first with 'make build' or set BANGER_BIN" exit 1 } find_latest_module_dir() { local root="$1" if [[ ! -d "$root" ]]; then return 1 fi find "$root" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 } resolve_release_branch() { local release="$1" printf 'v%s\n' "${release%.*}" } load_package_preset() { local preset="$1" local -n out="$2" mapfile -t out < <("$BANGER_BIN" internal packages "$preset") (( ${#out[@]} > 0 )) } write_rootfs_manifest_metadata() { local rootfs_path="$1" local manifest_hash="$2" printf '%s\n' "$manifest_hash" > "${rootfs_path}.packages.sha256" } install_root_authorized_key() { local public_key public_key="$(ssh-keygen -y -f "$SSH_KEY")" sudo mkdir -p "$ROOT_MOUNT/root/.ssh" printf '%s\n' "$public_key" | sudo tee "$ROOT_MOUNT/root/.ssh/authorized_keys" >/dev/null sudo chmod 700 "$ROOT_MOUNT/root/.ssh" sudo chmod 600 "$ROOT_MOUNT/root/.ssh/authorized_keys" } ensure_sshd_include() { local cfg="$ROOT_MOUNT/etc/ssh/sshd_config" local tmp_cfg="$TMP_DIR/sshd_config" local include_line="Include /etc/ssh/sshd_config.d/*.conf" sudo mkdir -p "$ROOT_MOUNT/etc/ssh/sshd_config.d" if sudo test -f "$cfg"; then sudo cat "$cfg" > "$tmp_cfg" else : > "$tmp_cfg" fi if ! grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf([[:space:]]|$)' "$tmp_cfg"; then { printf '%s\n' "$include_line" cat "$tmp_cfg" } > "${tmp_cfg}.new" mv "${tmp_cfg}.new" "$tmp_cfg" sudo install -m 0644 "$tmp_cfg" "$cfg" fi } normalize_root_shell() { local passwd="$ROOT_MOUNT/etc/passwd" local shells="$ROOT_MOUNT/etc/shells" local wanted_shell="/bin/bash" local tmp_passwd="$TMP_DIR/passwd" local root_shell="" if [[ ! -x "$ROOT_MOUNT$wanted_shell" ]]; then log "required root shell is missing from the Alpine image: $wanted_shell" exit 1 fi if [[ ! -f "$shells" ]]; then log "Alpine image is missing /etc/shells" exit 1 fi if ! sudo grep -Fxq "$wanted_shell" "$shells"; then log "Alpine image does not allow $wanted_shell in /etc/shells" exit 1 fi sudo cat "$passwd" > "$tmp_passwd" awk -F: -v OFS=: -v shell="$wanted_shell" ' $1 == "root" { $7 = shell found = 1 } { print } END { if (!found) { exit 1 } } ' "$tmp_passwd" > "${tmp_passwd}.new" || { log "failed to rewrite root shell in /etc/passwd" exit 1 } mv "${tmp_passwd}.new" "$tmp_passwd" sudo install -m 0644 "$tmp_passwd" "$passwd" root_shell="$(sudo awk -F: '$1 == "root" { print $7 }' "$passwd")" if [[ "$root_shell" != "$wanted_shell" ]]; then log "root shell normalization failed: expected $wanted_shell, got ${root_shell:-}" exit 1 fi } configure_root_bash_prompt() { local bashrc="$ROOT_MOUNT/root/.bashrc" local bash_profile="$ROOT_MOUNT/root/.bash_profile" local profile_prompt="$ROOT_MOUNT/etc/profile.d/banger-bash-prompt.sh" sudo mkdir -p "$ROOT_MOUNT/root" "$ROOT_MOUNT/etc/profile.d" cat <<'EOF' | sudo tee "$bashrc" >/dev/null # banger: default interactive prompt for experimental Alpine guests case "$-" in *i*) ;; *) return ;; esac if [ -z "${BANGER_MISE_ACTIVATED:-}" ] && [ -x '/usr/local/bin/mise' ]; then export BANGER_MISE_ACTIVATED=1 eval "$(/usr/local/bin/mise activate bash)" fi PS1='\u@\h:\w\$ ' EOF cat <<'EOF' | sudo tee "$bash_profile" >/dev/null if [ -f ~/.bashrc ]; then . ~/.bashrc fi EOF cat <<'EOF' | sudo tee "$profile_prompt" >/dev/null case "$-" in *i*) ;; *) return 0 2>/dev/null || exit 0 ;; esac if [ -n "${BASH_VERSION:-}" ]; then PS1='\u@\h:\w\$ ' fi EOF sudo chmod 0644 "$bashrc" "$bash_profile" "$profile_prompt" } install_guest_network_bootstrap() { sudo mkdir -p "$ROOT_MOUNT/usr/local/libexec" sudo install -m 0755 "$GUESTNET_BOOTSTRAP_SCRIPT" "$ROOT_MOUNT/usr/local/libexec/banger-network-bootstrap" } install_openrc_services() { local initd_dir="$ROOT_MOUNT/etc/init.d" sudo mkdir -p "$initd_dir" cat <<'EOF' | sudo tee "$initd_dir/banger-network" >/dev/null #!/sbin/openrc-run description="Banger guest network bootstrap" depend() { need localmount before sshd docker banger-vsock-agent banger-opencode provide net } start() { ebegin "Configuring guest network" /usr/local/libexec/banger-network-bootstrap eend $? } EOF cat <<'EOF' | sudo tee "$initd_dir/banger-docker-preflight" >/dev/null #!/sbin/openrc-run description="Banger Docker kernel preflight" depend() { after modules before docker } start() { ebegin "Preparing Docker kernel state" for module in nf_tables nft_chain_nat veth br_netfilter overlay; do modprobe "$module" 2>/dev/null || true done if command -v sysctl >/dev/null 2>&1; then sysctl -p /etc/sysctl.d/99-docker.conf >/dev/null 2>&1 || true fi eend 0 } EOF cat <<'EOF' | sudo tee "$initd_dir/banger-vsock-agent" >/dev/null #!/sbin/openrc-run description="Banger vsock agent" pidfile="/run/${RC_SVCNAME}.pid" command="/usr/local/bin/banger-vsock-agent" depend() { need localmount after banger-network } start_pre() { modprobe vsock 2>/dev/null || true modprobe vmw_vsock_virtio_transport 2>/dev/null || true } start() { ebegin "Starting ${RC_SVCNAME}" start-stop-daemon --start --exec "$command" --background --make-pidfile --pidfile "$pidfile" eend $? } stop() { ebegin "Stopping ${RC_SVCNAME}" start-stop-daemon --stop --exec "$command" --pidfile "$pidfile" eend $? } EOF cat <<'EOF' | sudo tee "$initd_dir/banger-opencode" >/dev/null #!/sbin/openrc-run description="Banger opencode server" pidfile="/run/${RC_SVCNAME}.pid" command="/usr/local/bin/opencode" command_args="serve --hostname 0.0.0.0 --port 4096" depend() { need localmount after banger-network } start() { ebegin "Starting ${RC_SVCNAME}" HOME=/root start-stop-daemon --start --exec "$command" --background --make-pidfile --pidfile "$pidfile" --chdir /root -- $command_args eend $? } stop() { ebegin "Stopping ${RC_SVCNAME}" start-stop-daemon --stop --exec "$command" --pidfile "$pidfile" eend $? } EOF sudo chmod 0755 \ "$initd_dir/banger-network" \ "$initd_dir/banger-docker-preflight" \ "$initd_dir/banger-vsock-agent" \ "$initd_dir/banger-opencode" } configure_docker_bootstrap() { local modules_conf="$ROOT_MOUNT/etc/modules-load.d/docker-netfilter.conf" local sysctl_conf="$ROOT_MOUNT/etc/sysctl.d/99-docker.conf" sudo mkdir -p "$ROOT_MOUNT/etc/modules-load.d" "$ROOT_MOUNT/etc/sysctl.d" cat <<'EOF' | sudo tee "$modules_conf" >/dev/null nf_tables nft_chain_nat veth br_netfilter overlay EOF cat <<'EOF' | sudo tee "$sysctl_conf" >/dev/null net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sudo chmod 0644 "$modules_conf" "$sysctl_conf" } configure_vsock_modules() { local modules_conf="$ROOT_MOUNT/etc/modules-load.d/banger-vsock.conf" sudo mkdir -p "$ROOT_MOUNT/etc/modules-load.d" cat <<'EOF' | sudo tee "$modules_conf" >/dev/null vsock vmw_vsock_virtio_transport EOF sudo chmod 0644 "$modules_conf" } configure_apk_repositories() { local repositories="$ROOT_MOUNT/etc/apk/repositories" sudo mkdir -p "$ROOT_MOUNT/etc/apk" cat </dev/null $APK_RELEASE_URL/main $APK_RELEASE_URL/community EOF sudo chmod 0644 "$repositories" if [[ -r /etc/resolv.conf ]]; then sudo install -m 0644 /etc/resolv.conf "$ROOT_MOUNT/etc/resolv.conf" fi } build_alpine_initramfs() { local kernel_version="$1" local guest_output="/boot/initramfs-${kernel_version}.img" local stage_output="$MANUAL_DIR/alpine-kernel/boot/initramfs-${kernel_version}.img" local mkinitfs_dir="$ROOT_MOUNT/etc/mkinitfs" local mkinitfs_conf="$mkinitfs_dir/mkinitfs.conf" sudo mkdir -p "$mkinitfs_dir" "$ROOT_MOUNT/boot" "$MANUAL_DIR/alpine-kernel/boot" cat <<'EOF' | sudo tee "$mkinitfs_conf" >/dev/null features="ata base ide scsi usb virtio ext4 nvme" EOF sudo chmod 0644 "$mkinitfs_conf" log "building Alpine initramfs for kernel $kernel_version" sudo env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ chroot "$ROOT_MOUNT" /bin/sh -se <&2 exit 1 fi ln -snf /root/.local/share/mise/shims/opencode /usr/local/bin/opencode EOF cat <<'EOF' | sudo tee "$profile_mise" >/dev/null if [ -n "${BASH_VERSION:-}" ] && [ -z "${BANGER_MISE_ACTIVATED:-}" ] && [ -x '/usr/local/bin/mise' ]; then export BANGER_MISE_ACTIVATED=1 eval "$(/usr/local/bin/mise activate bash)" fi EOF sudo chmod 0644 "$profile_mise" } enable_openrc_services() { sudo env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin chroot "$ROOT_MOUNT" /bin/sh -se <<'EOF' set -eu add_service() { local service="$1" local runlevel="$2" if [ ! -x "/etc/init.d/$service" ]; then echo "missing OpenRC service: $service" >&2 exit 1 fi rc-update add "$service" "$runlevel" >/dev/null } for service in devfs dmesg mdev; do add_service "$service" sysinit done for service in hwdrivers modules sysctl hostname bootmisc cgroups; do add_service "$service" boot done for service in banger-network sshd banger-docker-preflight docker banger-vsock-agent banger-opencode; do add_service "$service" default done for service in mount-ro killprocs; do add_service "$service" shutdown done EOF } cleanup() { if [[ "${SYS_MOUNTED:-0}" == "1" ]] && command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$ROOT_MOUNT/sys"; then sudo umount "$ROOT_MOUNT/sys" || true fi if [[ "${PROC_MOUNTED:-0}" == "1" ]] && command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$ROOT_MOUNT/proc"; then sudo umount "$ROOT_MOUNT/proc" || true fi if [[ "${DEVPTS_MOUNTED:-0}" == "1" ]] && command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$ROOT_MOUNT/dev/pts"; then sudo umount "$ROOT_MOUNT/dev/pts" || true fi if [[ "${DEV_MOUNTED:-0}" == "1" ]] && command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$ROOT_MOUNT/dev"; then sudo umount "$ROOT_MOUNT/dev" || true fi if [[ -n "${ROOT_MOUNT:-}" ]] && command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$ROOT_MOUNT"; then sudo umount "$ROOT_MOUNT" || true fi if [[ "${BUILD_DONE:-0}" != "1" ]]; then rm -f "${OUT_ROOTFS:-}" "${WORK_SEED:-}" "${OUT_ROOTFS:-}.packages.sha256" fi if [[ -n "${TMP_DIR:-}" && -d "${TMP_DIR:-}" ]]; then rm -rf "$TMP_DIR" fi } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" MANUAL_DIR="${BANGER_MANUAL_DIR:-$REPO_ROOT/build/manual}" BANGER_BIN="$(resolve_banger_bin)" SSH_KEY="$("$BANGER_BIN" internal ssh-key-path)" OUT_ROOTFS="$MANUAL_DIR/rootfs-alpine.ext4" SIZE_SPEC="2G" RELEASE="${ALPINE_RELEASE:-3.23.3}" MIRROR="https://dl-cdn.alpinelinux.org/alpine" ARCH="x86_64" MISE_VERSION="v2025.12.0" MISE_INSTALL_PATH="/usr/local/bin/mise" OPENCODE_TOOL="github:anomalyco/opencode" GUESTNET_BOOTSTRAP_SCRIPT="$REPO_ROOT/internal/guestnet/assets/bootstrap.sh" MODULES_DIR="" ALPINE_KERNEL_MODULES_DIR="$(find_latest_module_dir "$MANUAL_DIR/alpine-kernel/lib/modules" || true)" VSOCK_AGENT="$("$BANGER_BIN" internal vsock-agent-path)" if [[ -n "$ALPINE_KERNEL_MODULES_DIR" ]]; then MODULES_DIR="$ALPINE_KERNEL_MODULES_DIR" fi while [[ $# -gt 0 ]]; do case "$1" in --out) OUT_ROOTFS="${2:-}" shift 2 ;; --size) SIZE_SPEC="${2:-}" shift 2 ;; --release) RELEASE="${2:-}" shift 2 ;; --mirror) MIRROR="${2:-}" shift 2 ;; --arch) ARCH="${2:-}" shift 2 ;; -h|--help) usage exit 0 ;; *) log "unknown option: $1" usage exit 1 ;; esac done if [[ "$ARCH" != "x86_64" ]]; then log "unsupported arch: $ARCH" log "this experimental builder currently supports only x86_64" exit 1 fi if [[ -z "$MODULES_DIR" || ! -d "$MODULES_DIR" ]]; then log "modules dir not found; run 'make alpine-kernel' first" exit 1 fi if [[ ! -x "$VSOCK_AGENT" ]]; then log "vsock agent not found or not executable: $VSOCK_AGENT" log "run 'make build'" exit 1 fi if [[ ! -f "$GUESTNET_BOOTSTRAP_SCRIPT" ]]; then log "guest network bootstrap script not found: $GUESTNET_BOOTSTRAP_SCRIPT" exit 1 fi if [[ -e "$OUT_ROOTFS" ]]; then log "output rootfs already exists: $OUT_ROOTFS" exit 1 fi require_command curl require_command tar require_command sudo require_command mkfs.ext4 require_command ssh-keygen require_command mount require_command umount require_command install require_command find require_command awk require_command sed require_command sha256sum require_command truncate require_command mountpoint require_command chroot require_command cp ALPINE_PACKAGES=() if ! load_package_preset alpine ALPINE_PACKAGES; then log "alpine package preset is empty" exit 1 fi if ! PACKAGES_HASH="$(printf '%s\n' "${ALPINE_PACKAGES[@]}" | sha256sum | awk '{print $1}')"; then log "failed to hash package preset" exit 1 fi if ! SIZE_BYTES="$(parse_size "$SIZE_SPEC")"; then log "invalid size: $SIZE_SPEC" exit 1 fi if [[ "$OUT_ROOTFS" == *.ext4 ]]; then WORK_SEED="${OUT_ROOTFS%.ext4}.work-seed.ext4" else WORK_SEED="${OUT_ROOTFS}.work-seed" fi BRANCH="$(resolve_release_branch "$RELEASE")" RELEASE_DIR="$MIRROR/$BRANCH/releases/$ARCH" MINIROOTFS_URL="$RELEASE_DIR/alpine-minirootfs-$RELEASE-$ARCH.tar.gz" MINIROOTFS_SHA256_URL="$MINIROOTFS_URL.sha256" APK_RELEASE_URL="$MIRROR/$BRANCH" TMP_DIR="$(mktemp -d -t banger-alpine-rootfs-XXXXXX)" MINIROOTFS_ARCHIVE="$TMP_DIR/alpine-minirootfs.tar.gz" ROOT_MOUNT="$TMP_DIR/rootfs" BUILD_DONE=0 DEV_MOUNTED=0 DEVPTS_MOUNTED=0 PROC_MOUNTED=0 SYS_MOUNTED=0 trap cleanup EXIT mkdir -p "$ROOT_MOUNT" log "downloading Alpine minirootfs from $MINIROOTFS_URL" curl -fsSL "$MINIROOTFS_URL" -o "$MINIROOTFS_ARCHIVE" expected_sha="$(curl -fsSL "$MINIROOTFS_SHA256_URL" | awk '{print $1}')" actual_sha="$(sha256sum "$MINIROOTFS_ARCHIVE" | awk '{print $1}')" if [[ -z "$expected_sha" ]]; then log "failed to read SHA256 from $MINIROOTFS_SHA256_URL" exit 1 fi if [[ "$expected_sha" != "$actual_sha" ]]; then log "sha256 mismatch for $MINIROOTFS_URL" log "expected: $expected_sha" log "actual: $actual_sha" exit 1 fi log "creating $OUT_ROOTFS ($SIZE_SPEC)" truncate -s "$SIZE_BYTES" "$OUT_ROOTFS" mkfs.ext4 -F -m 0 -L banger-alpine-root "$OUT_ROOTFS" >/dev/null sudo mount -o loop "$OUT_ROOTFS" "$ROOT_MOUNT" log "unpacking Alpine minirootfs" sudo tar -xzf "$MINIROOTFS_ARCHIVE" -C "$ROOT_MOUNT" configure_apk_repositories mount_chroot_support log "installing Alpine packages into the rootfs" sudo env HOME=/root PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin \ chroot "$ROOT_MOUNT" /bin/sh -se <