Stop assuming one workstation layout for runtime artifacts, mapdns, and host tooling. The daemon and shell helpers now use portable mapdns configuration, and runtime bundles can carry bundle.json metadata for their default kernel, initrd, modules, rootfs, and helper paths. Load bundle metadata through config with a legacy layout fallback, thread mapdns_bin/mapdns_data_file through the Go and shell paths, and add command-scoped preflight checks for VM start, NAT, image build, work-disk resize, and SSH so missing tools or artifacts fail with actionable errors. Update the runtime-bundle manifest, docs, and tests to match the new model. Verified with go test ./..., make build, and bash -n customize.sh interactive.sh dns.sh make-rootfs.sh verify.sh.
416 lines
12 KiB
Bash
Executable file
416 lines
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
log() {
|
|
printf '[customize] %s\n' "$*"
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: ./customize.sh <base-rootfs> [--out <path>] [--size <size>] [--kernel <path>] [--initrd <path>] [--docker] [--modules <dir>]
|
|
|
|
Creates a copy of rootfs.ext4, optionally resizes it, boots a VM using 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
|
|
}
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
DEFAULT_RUNTIME_DIR="$SCRIPT_DIR"
|
|
if [[ -d "$SCRIPT_DIR/runtime" ]]; then
|
|
DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime"
|
|
fi
|
|
RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}"
|
|
if [[ ! -d "$RUNTIME_DIR" ]]; then
|
|
log "runtime bundle not found: $RUNTIME_DIR"
|
|
log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR"
|
|
exit 1
|
|
fi
|
|
source "$RUNTIME_DIR/dns.sh"
|
|
source "$RUNTIME_DIR/packages.sh"
|
|
STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/image-build}"
|
|
VM_ROOT="$STATE/vms"
|
|
mkdir -p "$VM_ROOT"
|
|
|
|
BUNDLE_METADATA="$RUNTIME_DIR/bundle.json"
|
|
|
|
bundle_path() {
|
|
local key="$1"
|
|
local fallback="$2"
|
|
local rel=""
|
|
|
|
if [[ -f "$BUNDLE_METADATA" ]] && command -v jq >/dev/null 2>&1; then
|
|
rel="$(jq -r --arg key "$key" '.[$key] // empty' "$BUNDLE_METADATA" 2>/dev/null || true)"
|
|
fi
|
|
if [[ -n "$rel" && "$rel" != "null" ]]; then
|
|
printf '%s\n' "$RUNTIME_DIR/$rel"
|
|
return
|
|
fi
|
|
printf '%s\n' "$fallback"
|
|
}
|
|
|
|
BASE_ROOTFS="$RUNTIME_DIR/rootfs.ext4"
|
|
FC_BIN="$RUNTIME_DIR/firecracker"
|
|
|
|
KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")"
|
|
INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
|
|
SSH_KEY="$RUNTIME_DIR/id_ed25519"
|
|
NAT_SCRIPT="$RUNTIME_DIR/nat.sh"
|
|
|
|
BR_DEV="br-fc"
|
|
BR_IP="172.16.0.1"
|
|
CIDR="24"
|
|
DNS_SERVER="1.1.1.1"
|
|
|
|
BASE_ROOTFS=""
|
|
OUT_ROOTFS=""
|
|
SIZE_SPEC=""
|
|
INSTALL_DOCKER=0
|
|
MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")"
|
|
PACKAGES_FILE="$(banger_packages_file)"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--out)
|
|
OUT_ROOTFS="${2:-}"
|
|
shift 2
|
|
;;
|
|
--size)
|
|
SIZE_SPEC="${2:-}"
|
|
shift 2
|
|
;;
|
|
--kernel)
|
|
KERNEL="${2:-}"
|
|
shift 2
|
|
;;
|
|
--initrd)
|
|
INITRD="${2:-}"
|
|
shift 2
|
|
;;
|
|
--docker)
|
|
INSTALL_DOCKER=1
|
|
shift
|
|
;;
|
|
--modules)
|
|
MODULES_DIR="${2:-}"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
if [[ -z "$BASE_ROOTFS" ]]; then
|
|
BASE_ROOTFS="$1"
|
|
shift
|
|
else
|
|
log "unknown option: $1"
|
|
usage
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$BASE_ROOTFS" ]]; then
|
|
usage
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "$BASE_ROOTFS" ]]; then
|
|
log "base rootfs not found: $BASE_ROOTFS"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "$OUT_ROOTFS" ]]; then
|
|
base_dir="$(dirname "$BASE_ROOTFS")"
|
|
base_name="$(basename "$BASE_ROOTFS")"
|
|
OUT_ROOTFS="${base_dir}/docker-${base_name}"
|
|
fi
|
|
if [[ ! -f "$KERNEL" ]]; then
|
|
log "kernel not found: $KERNEL"
|
|
exit 1
|
|
fi
|
|
if [[ -n "$INITRD" && ! -f "$INITRD" ]]; then
|
|
log "initrd not found: $INITRD"
|
|
exit 1
|
|
fi
|
|
if [[ -n "$MODULES_DIR" && ! -d "$MODULES_DIR" ]]; then
|
|
log "modules dir not found: $MODULES_DIR"
|
|
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
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
log "jq required"
|
|
exit 1
|
|
fi
|
|
if ! command -v sha256sum >/dev/null 2>&1; then
|
|
log "sha256sum required to record package manifest metadata"
|
|
exit 1
|
|
fi
|
|
if [[ ! -f "$PACKAGES_FILE" ]]; then
|
|
log "package manifest not found: $PACKAGES_FILE"
|
|
exit 1
|
|
fi
|
|
|
|
APT_PACKAGES=()
|
|
if ! banger_packages_read_array APT_PACKAGES "$PACKAGES_FILE"; then
|
|
log "package manifest is empty: $PACKAGES_FILE"
|
|
exit 1
|
|
fi
|
|
if ! PACKAGES_HASH="$(printf '%s\n' "${APT_PACKAGES[@]}" | banger_packages_hash_stream)"; then
|
|
log "failed to hash package manifest: $PACKAGES_FILE"
|
|
exit 1
|
|
fi
|
|
printf -v APT_PACKAGES_ESCAPED '%q ' "${APT_PACKAGES[@]}"
|
|
|
|
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"
|
|
DNS_NAME=""
|
|
|
|
# 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"
|
|
banger_dns_remove_record_name "${DNS_NAME:-}"
|
|
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; }
|
|
|
|
log "configuring machine"
|
|
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/machine-config \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"vcpu_count": 2,
|
|
"mem_size_mib": 1024,
|
|
"smt": false
|
|
}' >/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} systemd.mask=home.mount systemd.mask=var.mount"
|
|
|
|
INITRD_JSON=""
|
|
if [[ -n "$INITRD" ]]; then
|
|
INITRD_JSON=", \"initrd_path\": \"$INITRD\""
|
|
fi
|
|
|
|
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\"${INITRD_JSON}
|
|
}" >/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
|
|
|
|
SUDO_CHILD_PID="$(pgrep -n -f "$API_SOCK" || true)"
|
|
if [[ -n "$SUDO_CHILD_PID" ]]; then
|
|
FC_PID="$SUDO_CHILD_PID"
|
|
fi
|
|
|
|
VM_CONFIG_JSON="$(sudo -E curl --unix-socket "$API_SOCK" -sS http://localhost/vm/config)"
|
|
CREATED_AT="$(date -Iseconds)"
|
|
DNS_NAME="$(banger_dns_name "$VM_NAME")"
|
|
banger_dns_write_record "$VM_NAME" "$GUEST_IP"
|
|
jq -n \
|
|
--arg id "$VM_ID" \
|
|
--arg name "$VM_NAME" \
|
|
--arg pid "$FC_PID" \
|
|
--arg created_at "$CREATED_AT" \
|
|
--arg guest_ip "$GUEST_IP" \
|
|
--arg tap "$TAP_DEV" \
|
|
--arg api_sock "$API_SOCK" \
|
|
--arg log "$LOG_FILE" \
|
|
--arg rootfs "$OUT_ROOTFS" \
|
|
--arg kernel "$KERNEL" \
|
|
--arg dns_name "$DNS_NAME" \
|
|
--argjson config "$VM_CONFIG_JSON" \
|
|
'{meta:{id:$id,name:$name,pid:$pid,created_at:$created_at,guest_ip:$guest_ip,tap:$tap,api_sock:$api_sock,log:$log,rootfs:$rootfs,kernel:$kernel,dns_name:$dns_name},config:$config}' \
|
|
> "$VM_DIR/vm.json"
|
|
|
|
log "enabling NAT for customization"
|
|
sudo -E "$NAT_SCRIPT" up "$VM_TAG" >/dev/null
|
|
|
|
log "waiting for SSH"
|
|
SSH_READY=0
|
|
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
|
|
SSH_READY=1
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [[ "$SSH_READY" -ne 1 ]]; then
|
|
log "ssh did not become ready on $GUEST_IP"
|
|
exit 1
|
|
fi
|
|
|
|
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
|
|
touch /etc/fstab
|
|
sed -i '\|^/dev/vdb[[:space:]]\+/home[[:space:]]|d; \|^/dev/vdc[[:space:]]\+/var[[:space:]]|d' /etc/fstab
|
|
if ! grep -q '^tmpfs /run ' /etc/fstab; then
|
|
echo 'tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0' >> /etc/fstab
|
|
fi
|
|
if ! grep -q '^tmpfs /tmp ' /etc/fstab; then
|
|
echo 'tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0' >> /etc/fstab
|
|
fi
|
|
apt-get update
|
|
DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
|
|
DEBIAN_FRONTEND=noninteractive apt-get -y install ${APT_PACKAGES_ESCAPED}
|
|
if [[ \"$INSTALL_DOCKER\" == \"1\" ]]; then
|
|
DEBIAN_FRONTEND=noninteractive apt-get -y remove containerd || true
|
|
if ! DEBIAN_FRONTEND=noninteractive apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then
|
|
DEBIAN_FRONTEND=noninteractive apt-get -y install docker.io
|
|
fi
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
systemctl enable --now docker || true
|
|
fi
|
|
fi
|
|
git config --system init.defaultBranch main
|
|
"
|
|
|
|
if [[ -n "$MODULES_DIR" ]]; then
|
|
MODULES_BASE="$(basename "$MODULES_DIR")"
|
|
log "copying kernel modules ($MODULES_BASE) into guest"
|
|
tar -C "$(dirname "$MODULES_DIR")" -cf - "$MODULES_BASE" | \
|
|
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
"root@${GUEST_IP}" bash -lc "set -e
|
|
mkdir -p /lib/modules
|
|
tar -C /lib/modules -xf -
|
|
depmod -a \"$MODULES_BASE\"
|
|
mkdir -p /etc/modules-load.d
|
|
printf 'nf_tables\nnft_chain_nat\nveth\nbr_netfilter\noverlay\n' > /etc/modules-load.d/docker-netfilter.conf
|
|
mkdir -p /etc/sysctl.d
|
|
cat > /etc/sysctl.d/99-docker.conf <<'SYSCTL'
|
|
net.bridge.bridge-nf-call-iptables = 1
|
|
net.bridge.bridge-nf-call-ip6tables = 1
|
|
net.ipv4.ip_forward = 1
|
|
SYSCTL
|
|
sysctl --system >/dev/null 2>&1 || true
|
|
sync
|
|
"
|
|
fi
|
|
|
|
log "shutting down guest"
|
|
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
"root@${GUEST_IP}" bash -lc "sync" || true
|
|
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/actions \
|
|
-H "Content-Type: application/json" \
|
|
-d '{ "action_type": "SendCtrlAltDel" }' >/dev/null || true
|
|
for _ in $(seq 1 200); do
|
|
if ! ps -p "$FC_PID" >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
sleep 0.05
|
|
done
|
|
banger_write_rootfs_manifest_metadata "$OUT_ROOTFS" "$PACKAGES_HASH"
|
|
log "done"
|