#!/bin/bash # Builds the ACPC 2026 contest image from the official ICPC image. # # 1. Download the ICPC image (streamed: checksum, decompression and a # sparse raw file in one pass, the .gz is not kept). # 2. Create build/acpc2026.qcow2, an overlay on top of the ICPC image. # The ICPC image itself is never modified. # 3. Allow SSH password login in the overlay, offline (debugfs through a # qemu-storage-daemon FUSE export, no root needed). # 4. Boot the overlay in QEMU/KVM and configure it with Ansible (site.yml). # 5. Power off. The result is written to USB sticks with write_sticks.sh, # or exported to a raw image with --export. # # Usage: ./build.sh --admin-pass PASSWORD [options] (./build.sh -h for the list) set -euo pipefail IMG_URL=${IMG_URL:-https://image.icpc.global/icpc2027/icpc2027-2026-09-29_image-amd64.img.gz} IMG_SHA256=${IMG_SHA256:-e81f1cb46abd19782c2bcbccbad2dca63a198650b38ba5b4f7b54f3f29fd1045} BASE_IMG=${BASE_IMG:-} ADMIN_PASS=${ADMIN_PASS:-} TEAM_PASS=${TEAM_PASS:-} STOCK_PASS=contest # password of the team account in the ICPC image RAM=${RAM:-4096} CPUS=${CPUS:-4} PORT_SSH=${PORT_SSH:-2222} FRESH=0 EXPORT=0 usage() { cat <<'EOF' Usage: ./build.sh --admin-pass PASSWORD [options] --admin-pass PASS New password of the administrator account (icpc), also asked to edit the boot menu (user admin). Required. --team-pass PASS New password of the team account, used by the teams. Default: unchanged (contest, as in the ICPC image). --base-image FILE Use an ICPC image already decompressed (raw .img) instead of downloading it. --fresh Start again from the ICPC image (drops build/acpc2026.qcow2). --export After the build, also write build/acpc2026.img (raw, sparse), for tools that cannot read qcow2. -h, --help Show this help. Environment: IMG_URL, IMG_SHA256, RAM (MB), CPUS, PORT_SSH. EOF } while [ $# -gt 0 ]; do case "$1" in --admin-pass) ADMIN_PASS=$2; shift 2 ;; --team-pass) TEAM_PASS=$2; shift 2 ;; --base-image) BASE_IMG=$2; shift 2 ;; --fresh) FRESH=1; shift ;; --export) EXPORT=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; esac done cd "$(dirname "$0")" ROOT=$(pwd) BUILD=$ROOT/build mkdir -p "$BUILD" [ -n "$BASE_IMG" ] || BASE_IMG=$BUILD/$(basename "${IMG_URL%.gz}") BASE_IMG=$(realpath -m "$BASE_IMG") OVERLAY=$BUILD/acpc2026.qcow2 VARS=$BUILD/OVMF_VARS.fd MON=$BUILD/qemu-monitor.sock FUSE=$BUILD/fuse.raw log() { printf '\033[1;36m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; } fail() { printf '\033[1;31m[%s] ERROR\033[0m %s\n' "$(date +%H:%M:%S)" "$*" >&2; exit 1; } [ -n "$ADMIN_PASS" ] || { usage >&2; fail "--admin-pass is required"; } for tool in qemu-system-x86_64 qemu-img qemu-storage-daemon fusermount3 debugfs \ sfdisk ansible-playbook sshpass curl python3; do command -v "$tool" >/dev/null || fail "missing tool: $tool" done [ -w /dev/kvm ] || fail "/dev/kvm is not available or not writable" OVMF_CODE="" for dir in /usr/share/edk2/x64 /usr/share/OVMF /usr/share/edk2/ovmf; do for code in OVMF_CODE.4m.fd OVMF_CODE_4M.fd OVMF_CODE.fd; do if [ -f "$dir/$code" ]; then OVMF_CODE=$dir/$code OVMF_VARS=$dir/${code/CODE/VARS} break 2 fi done done [ -n "$OVMF_CODE" ] && [ -f "$OVMF_VARS" ] || fail "UEFI firmware (OVMF) not found, install edk2-ovmf" vm_running() { pgrep -f "^qemu-system-x86_64 -name acpc2026-build" >/dev/null; } monitor() { python3 -I - "$MON" "$1" <<'PY' import socket, sys, time s = socket.socket(socket.AF_UNIX) s.connect(sys.argv[1]) time.sleep(0.2) s.sendall((sys.argv[2] + "\n").encode()) time.sleep(0.5) PY } stop_vm() { vm_running || return 0 monitor system_powerdown 2>/dev/null || true for _ in $(seq 1 100); do vm_running || return 0; sleep 3; done monitor quit 2>/dev/null || true sleep 2 } cleanup() { stop_vm if mountpoint -q "$FUSE" 2>/dev/null; then fusermount3 -u "$FUSE"; fi pkill -f "^qemu-storage-daemon .*$OVERLAY" 2>/dev/null || true rm -f "$BUILD/vars.yml" } trap cleanup EXIT # --- 1. ICPC image ------------------------------------------------------------ if [ -f "$BASE_IMG" ]; then log "1. ICPC image already present: $BASE_IMG" else log "1. Downloading $IMG_URL" curl -fL --retry 5 "$IMG_URL" \ | tee >(sha256sum | awk '{print $1}' > "$BASE_IMG.sha256") \ | gzip -dc | dd of="$BASE_IMG.part" bs=4M conv=sparse status=none sleep 1 got=$(cat "$BASE_IMG.sha256") [ "$got" = "$IMG_SHA256" ] || fail "SHA-256 mismatch: got $got, expected $IMG_SHA256" mv "$BASE_IMG.part" "$BASE_IMG" log " SHA-256 OK" fi # --- 2. Overlay --------------------------------------------------------------- if [ $FRESH -eq 1 ] || [ ! -f "$OVERLAY" ]; then log "2. Creating the overlay $OVERLAY" qemu-img create -q -f qcow2 -b "$BASE_IMG" -F raw "$OVERLAY" cp "$OVMF_VARS" "$VARS" else log "2. Reusing the overlay $OVERLAY (--fresh to start again)" [ -f "$VARS" ] || cp "$OVMF_VARS" "$VARS" fi # --- 3. Offline changes for the build ----------------------------------------- # The ICPC image refuses SSH password logins and denies the group "teams", to # which the team account belongs. For the build, icpc.conf is removed and # 00-build.conf allows password logins. finish.yml puts icpc.conf back. The # firewall is turned off for the boot, in case the overlay was already built # (site.yml turns it back on). log "3. Allowing SSH password login in the overlay" touch "$FUSE" qemu-storage-daemon \ --blockdev driver=file,node-name=file,filename="$OVERLAY" \ --blockdev driver=qcow2,node-name=disk,file=file \ --export type=fuse,id=exp,node-name=disk,mountpoint="$FUSE",writable=on \ > "$BUILD/qsd.log" 2>&1 & for _ in $(seq 1 20); do mountpoint -q "$FUSE" && break; sleep 0.5; done mountpoint -q "$FUSE" || fail "qemu-storage-daemon did not start (see $BUILD/qsd.log)" # The root file system is the largest partition start=$(sfdisk -J "$FUSE" | python3 -I -c ' import json, sys parts = json.load(sys.stdin)["partitiontable"]["partitions"] print(max(parts, key=lambda p: p["size"])["start"])') DEV="$FUSE?offset=$((start * 512))" printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication yes\nPermitRootLogin no\n' > "$BUILD/00-build.conf" dfs() { debugfs -w -R "$1" "$DEV" >/dev/null 2>&1 || true; } dfs "rm /etc/ssh/sshd_config.d/icpc.conf" dfs "rm /etc/ssh/sshd_config.d/00-build.conf" dfs "write $BUILD/00-build.conf /etc/ssh/sshd_config.d/00-build.conf" for field in "mode 0100644" "uid 0" "gid 0"; do dfs "set_inode_field /etc/ssh/sshd_config.d/00-build.conf $field" done dfs "rm /etc/systemd/system/multi-user.target.wants/firewall.service" debugfs -R "ls /etc/ssh/sshd_config.d" "$DEV" 2>/dev/null | grep -q 00-build.conf \ || fail "could not write 00-build.conf in the image" fusermount3 -u "$FUSE" pkill -f "^qemu-storage-daemon .*$OVERLAY" || true sleep 2 rm -f "$FUSE" "$BUILD/00-build.conf" # --- 4. Boot and configure ---------------------------------------------------- log "4. Booting the image (SSH on 127.0.0.1:$PORT_SSH)" vm_running && fail "a build VM is already running" qemu-system-x86_64 -name acpc2026-build -machine q35,accel=kvm -cpu host \ -m "$RAM" -smp "$CPUS" \ -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \ -drive if=pflash,format=raw,file="$VARS" \ -drive id=disk0,file="$OVERLAY",format=qcow2,if=none \ -device virtio-blk-pci,drive=disk0,bootindex=1 \ -netdev user,id=net0,hostfwd=tcp:127.0.0.1:"$PORT_SSH"-:22 \ -device virtio-net-pci,netdev=net0 \ -display none -monitor unix:"$MON",server,nowait \ -serial file:"$BUILD/serial.log" & ssh_ok() { sshpass -p "$2" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR -o ConnectTimeout=5 -o PreferredAuthentications=password \ -p "$PORT_SSH" "$1@127.0.0.1" 'sudo -n true 2>/dev/null; id -nG' 2>/dev/null | grep -qw sudo } # First build: the team account of the ICPC image (password contest, sudo). # Next builds: the icpc account with the new password (team lost sudo). LOGIN_USER="" for _ in $(seq 1 120); do vm_running || fail "QEMU stopped (see $BUILD/serial.log)" if ssh_ok icpc "$ADMIN_PASS"; then LOGIN_USER=icpc; LOGIN_PASS=$ADMIN_PASS; break; fi if ssh_ok team "$STOCK_PASS"; then LOGIN_USER=team; LOGIN_PASS=$STOCK_PASS; break; fi sleep 5 done [ -n "$LOGIN_USER" ] || fail "no SSH login with sudo after 10 minutes (icpc with --admin-pass, or team/contest)" log " SSH login OK ($LOGIN_USER)" # Passwords go through a file readable only by the user, not the command line ( umask 077; python3 -I - "$BUILD/vars.yml" "$LOGIN_PASS" "$ADMIN_PASS" "$TEAM_PASS" <<'PY' import json, sys path, login, admin, team = sys.argv[1:] json.dump({"ansible_password": login, "ansible_become_password": login, "admin_pass": admin, "team_pass": team}, open(path, "w")) PY ) log " Running site.yml" ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -i "127.0.0.1," -u "$LOGIN_USER" \ -e ansible_port="$PORT_SSH" -e ansible_become_exe=sudo.ws \ -e "@$BUILD/vars.yml" -e start_firewall=false site.yml rm -f "$BUILD/vars.yml" # --- 5. Power off ------------------------------------------------------------- # finish.yml powers the machine off as its last task log "5. Waiting for the image to power off" for _ in $(seq 1 200); do vm_running || break; sleep 3; done stop_vm vm_running && fail "the VM did not power off" if [ $EXPORT -eq 1 ]; then log " Exporting $BUILD/acpc2026.img" qemu-img convert -p -O raw "$OVERLAY" "$BUILD/acpc2026.img" fi cat <