diff --git a/customize.sh b/customize.sh new file mode 100755 index 0000000..33e796b --- /dev/null +++ b/customize.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[customize] %s\n' "$*" +} + +usage() { + cat <<'EOF' +Usage: ./customize.sh [--size ] + +Creates a copy of rootfs.ext4, optionally resizes it, boots a VM with the +copy as a writable rootfs, then applies base configuration and packages. +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) echo $((num * 1024)) ;; + M|"") echo $((num * 1024 * 1024)) ;; + G) echo $((num * 1024 * 1024 * 1024)) ;; + esac + return 0 + fi + return 1 +} + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE="$DIR/state" +VM_ROOT="$STATE/vms" +mkdir -p "$VM_ROOT" + +BASE_ROOTFS="$DIR/rootfs.ext4" +FC_BIN="$DIR/firecracker" +KERNEL="$DIR/vmlinux" +SSH_KEY="$DIR/id_ed25519" + +BR_DEV="br-fc" +BR_IP="172.16.0.1" +CIDR="24" +DNS_SERVER="1.1.1.1" + +OUT_ROOTFS="" +SIZE_SPEC="" +while [[ $# -gt 0 ]]; do + case "$1" in + --size) + SIZE_SPEC="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ -z "$OUT_ROOTFS" ]]; then + OUT_ROOTFS="$1" + shift + else + log "unknown option: $1" + usage + exit 1 + fi + ;; + esac +done + +if [[ -z "$OUT_ROOTFS" ]]; then + usage + exit 1 +fi + +if [[ ! -f "$BASE_ROOTFS" ]]; then + log "base rootfs not found: $BASE_ROOTFS" + exit 1 +fi + +if [[ -e "$OUT_ROOTFS" ]]; then + log "output rootfs already exists: $OUT_ROOTFS" + exit 1 +fi + +if ! command -v resize2fs >/dev/null 2>&1; then + log "resize2fs required" + exit 1 +fi + +log "copying base rootfs to $OUT_ROOTFS" +cp --reflink=auto "$BASE_ROOTFS" "$OUT_ROOTFS" + +if [[ -n "$SIZE_SPEC" ]]; then + SIZE_BYTES="$(parse_size "$SIZE_SPEC")" + BASE_BYTES="$(stat -c%s "$BASE_ROOTFS")" + if [[ -z "$SIZE_BYTES" || "$SIZE_BYTES" -lt "$BASE_BYTES" ]]; then + log "size must be >= base image size" + exit 1 + fi + log "resizing rootfs to $SIZE_SPEC" + truncate -s "$SIZE_BYTES" "$OUT_ROOTFS" + e2fsck -p -f "$OUT_ROOTFS" >/dev/null + resize2fs "$OUT_ROOTFS" >/dev/null +fi + +VM_ID="$(head -c 32 /dev/urandom | xxd -p -c 256)" +VM_TAG="${VM_ID:0:8}" +VM_NAME="customize-${VM_TAG}" +VM_DIR="$VM_ROOT/$VM_ID" +mkdir -p "$VM_DIR" + +API_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/banger/fc-$VM_TAG.sock" +LOG_FILE="$VM_DIR/firecracker.log" +TAP_DEV="tap-fc-$VM_TAG" + +# Allocate guest IP +NEXT_IP_FILE="$STATE/next_ip" +NEXT_IP="$(cat "$NEXT_IP_FILE" 2>/dev/null || echo 2)" +GUEST_IP="172.16.0.$NEXT_IP" +echo "$((NEXT_IP + 1))" > "$NEXT_IP_FILE" + +sudo -v + +cleanup() { + sudo kill "${FC_PID:-}" 2>/dev/null || true + sudo ip link del "$TAP_DEV" 2>/dev/null || true + rm -f "$API_SOCK" + rm -rf "$VM_DIR" +} +trap cleanup EXIT + +sudo mkdir -p "$(dirname "$API_SOCK")" +sudo chown "$(id -u):$(id -g)" "$(dirname "$API_SOCK")" + +# Host bridge +if ! ip link show "$BR_DEV" >/dev/null 2>&1; then + log "creating host bridge $BR_DEV ($BR_IP/$CIDR)" + sudo ip link add name "$BR_DEV" type bridge + sudo ip addr add "${BR_IP}/${CIDR}" dev "$BR_DEV" + sudo ip link set "$BR_DEV" up +else + sudo ip link set "$BR_DEV" up +fi + +log "creating tap device $TAP_DEV" +TAP_USER="${SUDO_UID:-$(id -u)}" +TAP_GROUP="${SUDO_GID:-$(id -g)}" +sudo ip tuntap add dev "$TAP_DEV" mode tap user "$TAP_USER" group "$TAP_GROUP" +sudo ip link set "$TAP_DEV" master "$BR_DEV" +sudo ip link set "$TAP_DEV" up +sudo ip link set "$BR_DEV" up + +log "starting firecracker process" +rm -f "$API_SOCK" +nohup sudo -E "$FC_BIN" --api-sock "$API_SOCK" >"$LOG_FILE" 2>&1 & +FC_PID="$!" + +log "waiting for firecracker api socket" +for _ in $(seq 1 200); do + [[ -S "$API_SOCK" ]] && break + sleep 0.02 +done +[[ -S "$API_SOCK" ]] || { log "firecracker api socket not ready"; exit 1; } + +cat > "$VM_DIR/info" </dev/null + +KCMD="console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=${GUEST_IP}::${BR_IP}:255.255.255.0:${VM_NAME}:eth0:off:${DNS_SERVER} hostname=${VM_NAME}" + +sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/boot-source \ + -H "Content-Type: application/json" \ + -d "{ + \"kernel_image_path\": \"$KERNEL\", + \"boot_args\": \"$KCMD\" + }" >/dev/null + +sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/drives/rootfs \ + -H "Content-Type: application/json" \ + -d "{ + \"drive_id\": \"rootfs\", + \"path_on_host\": \"$OUT_ROOTFS\", + \"is_root_device\": true, + \"is_read_only\": false + }" >/dev/null + +sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/network-interfaces/eth0 \ + -H "Content-Type: application/json" \ + -d "{ + \"iface_id\": \"eth0\", + \"host_dev_name\": \"$TAP_DEV\" + }" >/dev/null + +sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/actions \ + -H "Content-Type: application/json" \ + -d '{ "action_type": "InstanceStart" }' >/dev/null + +log "enabling NAT for customization" +sudo -E ./nat.sh up "$VM_TAG" >/dev/null + +log "waiting for SSH" +for _ in $(seq 1 60); do + if ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + "root@${GUEST_IP}" "true" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +log "configuring guest" +ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + "root@${GUEST_IP}" bash -lc "set -e +printf 'nameserver %s\n' \"$DNS_SERVER\" > /etc/resolv.conf +echo \"$VM_NAME\" > /etc/hostname +printf '127.0.0.1 localhost\n127.0.1.1 %s\n' \"$VM_NAME\" > /etc/hosts +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get -y upgrade +DEBIAN_FRONTEND=noninteractive apt-get -y install git less tree ca-certificates curl +git config --system init.defaultBranch main +" + +log "customization complete; shutting down" +sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/actions \ + -H "Content-Type: application/json" \ + -d '{ "action_type": "SendCtrlAltDel" }' >/dev/null || true + +sleep 2 +log "done"