diff --git a/README.md b/README.md index 1d8e0b3..f79659f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Minimal Firecracker launcher. - `rootfs.ext4`: guest root filesystem (base image if present) - `rootfs-docker.ext4`: docker-ready guest rootfs (built via `make-rootfs.sh`) - `id_ed25519`: SSH key for `root` +- `mapdns`: local DNS mapping CLI used to publish `.vm` → guest IP records ## Run ``` @@ -38,6 +39,7 @@ Minimal Firecracker launcher. ## Storage Layout - `rootfs.ext4` is used as the read-only origin for a per-VM device-mapper snapshot mounted as `/`. - Each VM gets writable ext4 disks mounted at `/home` and `/var`. +- `run.sh` seeds those `/home` and `/var` disks from the rootfs snapshot before boot so the guest sees the base image contents there on first boot. - The base image must include `/etc/fstab` entries for `/dev/vdb` → `/home` and `/dev/vdc` → `/var`. - `/run` and `/tmp` should be tmpfs via `/etc/fstab`. @@ -46,6 +48,16 @@ Minimal Firecracker launcher. ssh -i "./id_ed25519" root@ ``` +Shortcut: +``` +./ssh.sh +``` + +## VM DNS +- Spawned VMs register `.vm` → guest IP through `mapdns set`. +- VM teardown removes the mapping through `mapdns rm`. +- `mapdns` writes to `/home/thales/.local/share/mapdns/records.json`. + ## Internet Access VMs do not get internet access by default. You must enable forwarding and NAT: ``` @@ -102,10 +114,22 @@ invoke `make-rootfs.sh` to build it. `make-rootfs.sh` chooses the first available base image: - `./rootfs.ext4` +## Interactive Customization +To create a writable copy and customize it manually over SSH (no automatic +package/config changes), use: + +``` +./interactive.sh ./rootfs-docker.ext4 +``` + +You can override the output path: +``` +./interactive.sh ./rootfs-docker.ext4 --out ./my-rootfs.ext4 +``` + ## VM Info File Each VM writes: -- `state/vms//vm.json`: raw `/vm/config` response from Firecracker. -- `state/vms//meta.json`: local metadata (id, name, pid, created_at, guest_ip, tap, api_sock, log, rootfs, kernel, snapshot info). +- `state/vms//vm.json`: local metadata under `.meta` plus the raw Firecracker config under `.config`. ## Log Notes - `PCI: Fatal: No config space access function found` and `MissingAddressRange` lines are expected with `pci=off` in `run.sh`. diff --git a/customize.sh b/customize.sh index 91a373e..ea1ea5e 100755 --- a/customize.sh +++ b/customize.sh @@ -30,6 +30,7 @@ parse_size() { } DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/dns.sh" STATE="$DIR/state" VM_ROOT="$STATE/vms" mkdir -p "$VM_ROOT" @@ -161,6 +162,7 @@ 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" @@ -174,6 +176,7 @@ 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 @@ -211,23 +214,6 @@ for _ in $(seq 1 200); do done [[ -S "$API_SOCK" ]] || { log "firecracker api socket not ready"; exit 1; } -VM_CONFIG_JSON="$(sudo -E curl --unix-socket "$API_SOCK" -sS http://localhost/vm/config)" -CREATED_AT="$(date -Iseconds)" -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" \ - --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},config:$config}' \ - > "$VM_DIR/vm.json" - log "configuring machine" sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/machine-config \ -H "Content-Type: application/json" \ @@ -271,6 +257,31 @@ 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.sh up "$VM_TAG" >/dev/null diff --git a/dns.sh b/dns.sh new file mode 100644 index 0000000..9438e44 --- /dev/null +++ b/dns.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +MAPDNS_BIN="${MAPDNS_BIN:-mapdns}" +MAPDNS_DATA_FILE="/home/thales/.local/share/mapdns/records.json" + +banger_mapdns_cmd() { + local subcommand="$1" + shift + + "$MAPDNS_BIN" "$subcommand" --data-file "$MAPDNS_DATA_FILE" "$@" +} + +banger_dns_name() { + local vm_name="$1" + printf '%s.vm' "$vm_name" +} + +banger_dns_write_record() { + local vm_name="$1" + local guest_ip="$2" + local dns_name + + mkdir -p "$(dirname "$MAPDNS_DATA_FILE")" + dns_name="$(banger_dns_name "$vm_name")" + banger_mapdns_cmd set "$dns_name" "$guest_ip" >/dev/null +} + +banger_dns_record_exists() { + local dns_name="$1" + + [[ -n "$dns_name" ]] || return 1 + banger_mapdns_cmd list | awk '{print $1}' | rg -Fxq "$dns_name" +} + +banger_dns_remove_record_name() { + local dns_name="${1:-}" + [[ -n "$dns_name" ]] || return 0 + if ! banger_dns_record_exists "$dns_name"; then + return 0 + fi + banger_mapdns_cmd rm "$dns_name" >/dev/null +} + +banger_vm_process_running() { + local pid="$1" + local api_sock="$2" + + [[ -n "$pid" && -n "$api_sock" ]] || return 1 + ps -p "$pid" -o comm=,args= 2>/dev/null | rg -q "firecracker.*--api-sock $api_sock" +} + +banger_wait_for_vm_exit() { + local pid="$1" + local api_sock="$2" + local timeout_secs="${3:-30}" + local deadline=$((SECONDS + timeout_secs)) + + while banger_vm_process_running "$pid" "$api_sock"; do + if (( SECONDS >= deadline )); then + return 1 + fi + sleep 0.1 + done +} + +banger_teardown_vm_runtime() { + local tap="${1:-}" + local api_sock="${2:-}" + local dm_name="${3:-}" + local dm_dev="${4:-}" + local cow_loop="${5:-}" + local base_loop="${6:-}" + + if [[ -n "$tap" ]]; then + sudo ip link del "$tap" 2>/dev/null || true + fi + if [[ -n "$api_sock" ]]; then + rm -f "$api_sock" + fi + if [[ -n "$dm_name" || -n "$dm_dev" ]]; then + sudo dmsetup remove "${dm_name:-$dm_dev}" 2>/dev/null || true + fi + if [[ -n "$cow_loop" ]]; then + sudo losetup -d "$cow_loop" 2>/dev/null || true + fi + if [[ -n "$base_loop" ]]; then + sudo losetup -d "$base_loop" 2>/dev/null || true + fi +} + +banger_mark_vm_stopped() { + local vm_json="$1" + + [[ -f "$vm_json" ]] || return 0 + jq \ + 'del(.meta.pid, .meta.base_loop, .meta.cow_loop, .meta.dm_dev)' \ + "$vm_json" > "$vm_json.tmp" && mv "$vm_json.tmp" "$vm_json" +} diff --git a/interactive.sh b/interactive.sh new file mode 100755 index 0000000..dc04315 --- /dev/null +++ b/interactive.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[interactive] %s\n' "$*" +} + +usage() { + cat <<'EOF' +Usage: ./interactive.sh [--out ] [--size ] [--home-size ] [--var-size ] + +Creates a writable copy of the base rootfs and boots a VM so you can +customize it manually over SSH. No automatic package/config changes +are applied. +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)" +source "$DIR/dns.sh" +STATE="$DIR/state" +VM_ROOT="$STATE/vms" +mkdir -p "$VM_ROOT" + +FC_BIN="$DIR/firecracker" +KERNEL="$DIR/wtf/root/boot/vmlinux-6.8.0-94-generic" +INITRD="$DIR/wtf/root/boot/initrd.img-6.8.0-94-generic" +SSH_KEY="$DIR/id_ed25519" + +BR_DEV="br-fc" +BR_IP="172.16.0.1" +CIDR="24" +DNS_SERVER="1.1.1.1" +DEFAULT_HOME_SIZE="2G" +DEFAULT_VAR_SIZE="2G" + +BASE_ROOTFS="" +OUT_ROOTFS="" +SIZE_SPEC="" +HOME_SIZE="$DEFAULT_HOME_SIZE" +VAR_SIZE="$DEFAULT_VAR_SIZE" +while [[ $# -gt 0 ]]; do + case "$1" in + --out) + OUT_ROOTFS="${2:-}" + shift 2 + ;; + --size) + SIZE_SPEC="${2:-}" + shift 2 + ;; + --home-size) + HOME_SIZE="${2:-}" + shift 2 + ;; + --var-size) + VAR_SIZE="${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 [[ ! -f "$KERNEL" ]]; then + log "kernel not found: $KERNEL" + exit 1 +fi +if [[ ! -f "$INITRD" ]]; then + log "initrd not found: $INITRD" + exit 1 +fi + +if [[ -z "$OUT_ROOTFS" ]]; then + base_dir="$(dirname "$BASE_ROOTFS")" + base_name="$(basename "$BASE_ROOTFS")" + OUT_ROOTFS="${base_dir}/rw-${base_name}" +fi +if [[ -e "$OUT_ROOTFS" ]]; then + log "output rootfs already exists: $OUT_ROOTFS" + exit 1 +fi + +HOME_BYTES="$(parse_size "$HOME_SIZE")" || { log "invalid --home-size value: $HOME_SIZE"; exit 1; } +VAR_BYTES="$(parse_size "$VAR_SIZE")" || { log "invalid --var-size value: $VAR_SIZE"; exit 1; } + +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="interactive-${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" +HOME_PATH="$VM_DIR/home.ext4" +VAR_PATH="$VM_DIR/var.ext4" +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 + +if ! command -v mkfs.ext4 >/dev/null 2>&1; then + log "mkfs.ext4 required to create home/var disks" + exit 1 +fi +truncate -s "$HOME_BYTES" "$HOME_PATH" +mkfs.ext4 -F "$HOME_PATH" >/dev/null +truncate -s "$VAR_BYTES" "$VAR_PATH" +mkfs.ext4 -F "$VAR_PATH" >/dev/null + +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}" + +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_path\": \"$INITRD\" + }" >/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/drives/home \ + -H "Content-Type: application/json" \ + -d "{ + \"drive_id\": \"home\", + \"path_on_host\": \"$HOME_PATH\", + \"is_root_device\": false, + \"is_read_only\": false + }" >/dev/null + +sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/drives/var \ + -H "Content-Type: application/json" \ + -d "{ + \"drive_id\": \"var\", + \"path_on_host\": \"$VAR_PATH\", + \"is_root_device\": false, + \"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 interactive session" +sudo -E ./nat.sh up "$VM_TAG" >/dev/null + +log "waiting for SSH" +log "guest ip: $GUEST_IP" +log "ssh: ssh -i \"$SSH_KEY\" root@${GUEST_IP}" +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 + log "ssh ready" + break + fi + sleep 1 +done + +log "output rootfs: $OUT_ROOTFS" +log "press Ctrl+C to stop and clean up" + +while kill -0 "$FC_PID" >/dev/null 2>&1; do + sleep 1 +done diff --git a/kill.sh b/kill.sh index 9ee2081..90ebe55 100755 --- a/kill.sh +++ b/kill.sh @@ -5,9 +5,14 @@ log() { printf '[kill] %s\n' "$*" } +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/dns.sh" +STATE="$DIR/state" +VM_ROOT="$STATE/vms" + usage() { cat <<'EOF' -Usage: ./kill.sh [--signal SIGTERM|SIGKILL|...] +Usage: ./kill.sh ... [--signal SIGTERM|SIGKILL|...] Sends a signal to the Firecracker process. EOF @@ -17,7 +22,7 @@ find_vm_json() { local query="$1" local vm_json match_count=0 match="" - for vm_json in state/vms/*/vm.json; do + for vm_json in "$VM_ROOT"/*/vm.json; do [[ -f "$vm_json" ]] || continue local id name id="$(jq -r '.meta.id // empty' "$vm_json")" @@ -41,6 +46,7 @@ find_vm_json() { } SIGNAL="TERM" +QUERIES=() while [[ $# -gt 0 ]]; do case "$1" in --signal) @@ -52,40 +58,68 @@ while [[ $# -gt 0 ]]; do exit 0 ;; *) - if [[ -z "${QUERY:-}" ]]; then - QUERY="$1" - shift - continue - fi - log "unknown option: $1" - usage - exit 1 + QUERIES+=("$1") + shift ;; esac done -if [[ -z "$QUERY" || "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then +if (( ${#QUERIES[@]} == 0 )); then usage exit 1 fi -VM_JSON="$(find_vm_json "$QUERY")" -PID="$(jq -r '.meta.pid // empty' "$VM_JSON")" -API_SOCK="$(jq -r '.meta.api_sock // empty' "$VM_JSON")" -if [[ -z "$PID" ]]; then - log "pid not found in $VM_JSON" - exit 1 -fi -if [[ -z "$API_SOCK" ]]; then - log "api_sock not found in $VM_JSON" - exit 1 -fi +VM_JSONS=() +for query in "${QUERIES[@]}"; do + VM_JSON="$(find_vm_json "$query")" + already_added=0 + for existing_vm_json in "${VM_JSONS[@]}"; do + if [[ "$existing_vm_json" == "$VM_JSON" ]]; then + already_added=1 + break + fi + done + if (( already_added == 0 )); then + VM_JSONS+=("$VM_JSON") + fi +done -if ! ps -p "$PID" -o comm=,args= 2>/dev/null | rg -q "firecracker.*--api-sock $API_SOCK"; then - log "pid $PID does not match a running VM" - exit 1 -fi +for vm_json in "${VM_JSONS[@]}"; do + vm_id="$(jq -r '.meta.id // empty' "$vm_json")" + vm_name="$(jq -r '.meta.name // empty' "$vm_json")" + pid="$(jq -r '.meta.pid // empty' "$vm_json")" + tap="$(jq -r '.meta.tap // empty' "$vm_json")" + api_sock="$(jq -r '.meta.api_sock // empty' "$vm_json")" + base_loop="$(jq -r '.meta.base_loop // empty' "$vm_json")" + cow_loop="$(jq -r '.meta.cow_loop // empty' "$vm_json")" + dm_dev="$(jq -r '.meta.dm_dev // empty' "$vm_json")" + dm_name="$(jq -r '.meta.dm_name // empty' "$vm_json")" + dns_name="$(jq -r '.meta.dns_name // empty' "$vm_json")" + if [[ -z "$dns_name" ]]; then + dns_name="$(banger_dns_name "$vm_name")" + fi + if [[ -z "$pid" ]]; then + log "pid not found in $vm_json" + exit 1 + fi + if [[ -z "$api_sock" ]]; then + log "api_sock not found in $vm_json" + exit 1 + fi -log "sending SIG$SIGNAL to pid $PID" -sudo kill "-$SIGNAL" "$PID" -log "signal sent" + if ! ps -p "$pid" -o comm=,args= 2>/dev/null | rg -q "firecracker.*--api-sock $api_sock"; then + log "pid $pid does not match a running VM for ${vm_name:-$vm_json}" + exit 1 + fi + + log "sending SIG$SIGNAL to ${vm_name:-$vm_json} (pid $pid)" + sudo kill "-$SIGNAL" "$pid" + if ! banger_wait_for_vm_exit "$pid" "$api_sock" 30; then + log "timed out waiting for ${vm_name:-$vm_json} to exit" + exit 1 + fi + banger_teardown_vm_runtime "$tap" "$api_sock" "$dm_name" "$dm_dev" "$cow_loop" "$base_loop" + banger_mark_vm_stopped "$vm_json" + banger_dns_remove_record_name "$dns_name" + log "signal sent to ${vm_name:-$vm_json}" +done diff --git a/list.sh b/list.sh index 8342c6c..93bab50 100755 --- a/list.sh +++ b/list.sh @@ -3,6 +3,67 @@ set -euo pipefail shopt -s nullglob +format_count_unit() { + local count="$1" + local singular="$2" + local plural="$3" + + if (( count == 1 )); then + printf '%s %s ago' "$count" "$singular" + else + printf '%s %s ago' "$count" "$plural" + fi +} + +humanize_created_at() { + local created_at="$1" + local created_epoch elapsed + + if [[ -z "$created_at" ]]; then + printf '%s' "-" + return 0 + fi + + created_epoch="$(date -d "$created_at" +%s 2>/dev/null || true)" + if [[ -z "$created_epoch" ]]; then + printf '%s' "$created_at" + return 0 + fi + + elapsed=$((NOW_EPOCH - created_epoch)) + if (( elapsed < 0 )); then + elapsed=0 + fi + + if (( elapsed < 45 )); then + printf '%s' "a few moments ago" + elif (( elapsed < 90 )); then + printf '%s' "about a minute ago" + elif (( elapsed < 3600 )); then + format_count_unit "$((elapsed / 60))" "minute" "minutes" + elif (( elapsed < 5400 )); then + printf '%s' "about an hour ago" + elif (( elapsed < 86400 )); then + format_count_unit "$((elapsed / 3600))" "hour" "hours" + elif (( elapsed < 172800 )); then + printf '%s' "1 day ago" + elif (( elapsed < 604800 )); then + format_count_unit "$((elapsed / 86400))" "day" "days" + elif (( elapsed < 1209600 )); then + printf '%s' "1 week ago" + elif (( elapsed < 2592000 )); then + format_count_unit "$((elapsed / 604800))" "week" "weeks" + elif (( elapsed < 5184000 )); then + printf '%s' "1 month ago" + elif (( elapsed < 31536000 )); then + format_count_unit "$((elapsed / 2592000))" "month" "months" + elif (( elapsed < 63072000 )); then + printf '%s' "1 year ago" + else + format_count_unit "$((elapsed / 31536000))" "year" "years" + fi +} + statuses=() ids=() names=() @@ -10,14 +71,17 @@ ips=() created=() max_name=4 max_ip=2 +max_created=7 GREEN='\033[32m' YELLOW='\033[33m' RESET='\033[0m' +NOW_EPOCH="$(date +%s)" for vm_json in state/vms/*/vm.json; do id="$(jq -r '.meta.id // empty' "$vm_json")" name="$(jq -r '.meta.name // empty' "$vm_json")" created_at="$(jq -r '.meta.created_at // empty' "$vm_json")" + created_display="$(humanize_created_at "$created_at")" guest_ip="$(jq -r '.meta.guest_ip // empty' "$vm_json")" pid="$(jq -r '.meta.pid // empty' "$vm_json")" api_sock="$(jq -r '.meta.api_sock // empty' "$vm_json")" @@ -34,13 +98,16 @@ for vm_json in state/vms/*/vm.json; do ids+=("$short_id") names+=("$name") ips+=("$guest_ip") - created+=("$created_at") + created+=("$created_display") if (( ${#name} > max_name )); then max_name=${#name} fi if (( ${#guest_ip} > max_ip )); then max_ip=${#guest_ip} fi + if (( ${#created_display} > max_created )); then + max_created=${#created_display} + fi done for i in "${!ids[@]}"; do @@ -48,6 +115,6 @@ for i in "${!ids[@]}"; do if [[ "${statuses[$i]}" == "running" ]]; then color="$GREEN" fi - printf '%-10b %-12s %-*s %-*s %s\n' \ - "${color}[${statuses[$i]}]${RESET}" "${ids[$i]}" "$max_name" "${names[$i]}" "$max_ip" "${ips[$i]}" "${created[$i]}" + printf '%-10b %-12s %-*s %-*s %-*s\n' \ + "${color}[${statuses[$i]}]${RESET}" "${ids[$i]}" "$max_name" "${names[$i]}" "$max_ip" "${ips[$i]}" "$max_created" "${created[$i]}" done diff --git a/restore.sh b/restore.sh index 60af354..6bdf297 100755 --- a/restore.sh +++ b/restore.sh @@ -13,17 +13,20 @@ Restarts a VM using existing disks and COW snapshot. EOF } -find_vm_meta() { - local query="$1" - local meta_json match_count=0 match="" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/dns.sh" - for meta_json in state/vms/*/meta.json; do - [[ -f "$meta_json" ]] || continue +find_vm_json() { + local query="$1" + local vm_json match_count=0 match="" + + for vm_json in "$DIR"/state/vms/*/vm.json; do + [[ -f "$vm_json" ]] || continue local id name - id="$(jq -r '.id // empty' "$meta_json")" - name="$(jq -r '.name // empty' "$meta_json")" + id="$(jq -r '.meta.id // empty' "$vm_json")" + name="$(jq -r '.meta.name // empty' "$vm_json")" if [[ "$id" == "$query"* || "$name" == "$query"* ]]; then - match="$meta_json" + match="$vm_json" match_count=$((match_count + 1)) fi done @@ -41,12 +44,15 @@ find_vm_meta() { } QUERY="${1:-}" -if [[ -z "$QUERY" || "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then +if [[ "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then + usage + exit 0 +fi +if [[ -z "$QUERY" ]]; then usage exit 1 fi -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FC_BIN="$DIR/firecracker" if ! command -v jq >/dev/null 2>&1; then @@ -62,31 +68,39 @@ if ! command -v e2cp >/dev/null 2>&1 || ! command -v e2rm >/dev/null 2>&1; then exit 1 fi -META_JSON="$(find_vm_meta "$QUERY")" -VM_DIR="$(dirname "$META_JSON")" -VM_JSON="$VM_DIR/vm.json" +VM_JSON="$(find_vm_json "$QUERY")" +VM_DIR="$(dirname "$VM_JSON")" -ROOTFS="$(jq -r '.rootfs // empty' "$META_JSON")" -KERNEL="$(jq -r '.kernel // empty' "$META_JSON")" -HOME_PATH="$(jq -r '.home_path // empty' "$META_JSON")" -VAR_PATH="$(jq -r '.var_path // empty' "$META_JSON")" -TAP_DEV="$(jq -r '.tap // empty' "$META_JSON")" -API_SOCK="$(jq -r '.api_sock // empty' "$META_JSON")" -LOG_FILE="$(jq -r '.log // empty' "$META_JSON")" -GUEST_IP="$(jq -r '.guest_ip // empty' "$META_JSON")" -DM_NAME="$(jq -r '.dm_name // empty' "$META_JSON")" -COW_FILE="$(jq -r '.cow_file // empty' "$META_JSON")" +VM_ID="$(jq -r '.meta.id // empty' "$VM_JSON")" +VM_NAME="$(jq -r '.meta.name // empty' "$VM_JSON")" +PID="$(jq -r '.meta.pid // empty' "$VM_JSON")" +ROOTFS="$(jq -r '.meta.rootfs // empty' "$VM_JSON")" +KERNEL="$(jq -r '.meta.kernel // empty' "$VM_JSON")" +HOME_PATH="$(jq -r '.meta.home_path // empty' "$VM_JSON")" +VAR_PATH="$(jq -r '.meta.var_path // empty' "$VM_JSON")" +TAP_DEV="$(jq -r '.meta.tap // empty' "$VM_JSON")" +API_SOCK="$(jq -r '.meta.api_sock // empty' "$VM_JSON")" +LOG_FILE="$(jq -r '.meta.log // empty' "$VM_JSON")" +GUEST_IP="$(jq -r '.meta.guest_ip // empty' "$VM_JSON")" +DM_NAME="$(jq -r '.meta.dm_name // empty' "$VM_JSON")" +DM_DEV_OLD="$(jq -r '.meta.dm_dev // empty' "$VM_JSON")" +BASE_LOOP_OLD="$(jq -r '.meta.base_loop // empty' "$VM_JSON")" +COW_FILE="$(jq -r '.meta.cow_file // empty' "$VM_JSON")" +COW_LOOP_OLD="$(jq -r '.meta.cow_loop // empty' "$VM_JSON")" +INITRD_PATH="$(jq -r '.config["boot-source"].initrd_path // empty' "$VM_JSON")" +DNS_NAME="$(banger_dns_name "$VM_NAME")" if [[ -z "$ROOTFS" || -z "$KERNEL" || -z "$HOME_PATH" || -z "$VAR_PATH" || -z "$API_SOCK" || -z "$TAP_DEV" || -z "$GUEST_IP" || -z "$DM_NAME" || -z "$COW_FILE" ]]; then - log "meta.json missing required fields" + log "vm.json missing required fields" exit 1 fi if [[ ! -f "$ROOTFS" || ! -f "$KERNEL" || ! -f "$HOME_PATH" || ! -f "$VAR_PATH" || ! -f "$COW_FILE" || ! -f "$FC_BIN" ]]; then log "missing disk/kernel file(s)" exit 1 fi -if [[ ! -f "$VM_JSON" ]]; then - log "vm.json missing: $VM_JSON" + +if banger_vm_process_running "$PID" "$API_SOCK"; then + log "vm is already running: $VM_NAME" exit 1 fi @@ -99,11 +113,16 @@ DNS_SERVER="1.1.1.1" VM_STARTED=0 cleanup() { + local dm_dev_cleanup="${DM_DEV:-$DM_DEV_OLD}" + local cow_loop_cleanup="${COW_LOOP:-$COW_LOOP_OLD}" + local base_loop_cleanup="${BASE_LOOP:-$BASE_LOOP_OLD}" + if [[ "$VM_STARTED" -eq 1 ]]; then return fi - sudo ip link del "$TAP_DEV" 2>/dev/null || true - rm -f "$API_SOCK" + banger_teardown_vm_runtime "$TAP_DEV" "$API_SOCK" "$DM_NAME" "$dm_dev_cleanup" "$cow_loop_cleanup" "$base_loop_cleanup" + banger_mark_vm_stopped "$VM_JSON" + banger_dns_remove_record_name "${DNS_NAME:-}" } trap cleanup EXIT @@ -121,8 +140,10 @@ sock_dir="$(dirname "$API_SOCK")" sudo mkdir -p "$sock_dir" sudo chown "$(id -u):$(id -g)" "$sock_dir" +banger_teardown_vm_runtime "$TAP_DEV" "$API_SOCK" "$DM_NAME" "$DM_DEV_OLD" "$COW_LOOP_OLD" "$BASE_LOOP_OLD" +banger_mark_vm_stopped "$VM_JSON" + # Recreate dm-snapshot -sudo dmsetup remove "$DM_NAME" 2>/dev/null || true BASE_LOOP="$(sudo losetup -f --show --read-only "$ROOTFS")" COW_LOOP="$(sudo losetup -f --show "$COW_FILE")" SECTORS="$(sudo blockdev --getsz "$BASE_LOOP")" @@ -133,16 +154,16 @@ jq \ --arg base_loop "$BASE_LOOP" \ --arg cow_loop "$COW_LOOP" \ --arg dm_dev "$DM_DEV" \ - '.base_loop=$base_loop | .cow_loop=$cow_loop | .dm_dev=$dm_dev' \ - "$META_JSON" > "$META_JSON.tmp" && mv "$META_JSON.tmp" "$META_JSON" + '.meta.base_loop=$base_loop | .meta.cow_loop=$cow_loop | .meta.dm_dev=$dm_dev' \ + "$VM_JSON" > "$VM_JSON.tmp" && mv "$VM_JSON.tmp" "$VM_JSON" # Update /etc/resolv.conf and hostname in snapshot RESOLV_TMP="$VM_DIR/resolv.conf" HOSTNAME_TMP="$VM_DIR/hostname" HOSTS_TMP="$VM_DIR/hosts" printf 'nameserver %s\n' "$DNS_SERVER" >"$RESOLV_TMP" -printf '%s\n' "$(jq -r '.name // empty' "$META_JSON")" >"$HOSTNAME_TMP" -printf '127.0.0.1 localhost\n127.0.1.1 %s\n' "$(jq -r '.name // empty' "$META_JSON")" >"$HOSTS_TMP" +printf '%s\n' "$VM_NAME" >"$HOSTNAME_TMP" +printf '127.0.0.1 localhost\n127.0.1.1 %s\n' "$VM_NAME" >"$HOSTS_TMP" sudo e2rm "$DM_DEV:/etc/resolv.conf" >/dev/null 2>&1 || true sudo e2rm "$DM_DEV:/etc/hostname" >/dev/null 2>&1 || true sudo e2rm "$DM_DEV:/etc/hosts" >/dev/null 2>&1 || true @@ -171,22 +192,31 @@ for _ in $(seq 1 200); do done [[ -S "$API_SOCK" ]] || { log "firecracker api socket not ready"; exit 1; } +SUDO_CHILD_PID="$(pgrep -n -f "$API_SOCK" || true)" +if [[ -n "$SUDO_CHILD_PID" ]]; then + FC_PID="$SUDO_CHILD_PID" +fi + log "configuring machine" /usr/bin/sudo /usr/bin/curl --unix-socket "$API_SOCK" -X PUT http://localhost/machine-config \ -H "Content-Type: application/json" \ - -d "$(jq -c '."machine-config"' "$VM_JSON")" >/dev/null + -d "$(jq -c '.config["machine-config"]' "$VM_JSON")" >/dev/null -boot_args="$(jq -r '."boot-source".boot_args' "$VM_JSON")" +boot_args="$(jq -r '.config["boot-source"].boot_args // empty' "$VM_JSON")" boot_args="$(printf '%s' "$boot_args" | sed -E 's/(^| )hostname=[^ ]+//g; s/(^| )ip=[^ ]+//g' | awk '{$1=$1; print}')" boot_args="$boot_args ip=${GUEST_IP}::${BR_IP}:255.255.255.0::eth0:off:${DNS_SERVER}" -boot_args="$boot_args hostname=$(jq -r '.name // empty' "$META_JSON")" +boot_args="$boot_args hostname=$VM_NAME" +INITRD_JSON="" +if [[ -n "$INITRD_PATH" ]]; then + INITRD_JSON=", \"initrd_path\": \"$INITRD_PATH\"" +fi log "configuring boot source" /usr/bin/sudo /usr/bin/curl --unix-socket "$API_SOCK" -X PUT http://localhost/boot-source \ -H "Content-Type: application/json" \ -d "{ \"kernel_image_path\": \"$KERNEL\", - \"boot_args\": \"$boot_args\" + \"boot_args\": \"$boot_args\"${INITRD_JSON} }" >/dev/null log "attaching drives" @@ -229,16 +259,21 @@ log "starting virtual machine" /usr/bin/sudo /usr/bin/curl --unix-socket "$API_SOCK" -X PUT http://localhost/actions \ -H "Content-Type: application/json" \ -d '{ "action_type": "InstanceStart" }' >/dev/null -VM_STARTED=1 CREATED_AT="$(date -Iseconds)" +banger_dns_write_record "$VM_NAME" "$GUEST_IP" jq \ --arg pid "$FC_PID" \ --arg created_at "$CREATED_AT" \ - '.pid=$pid | .created_at=$created_at' \ - "$META_JSON" > "$META_JSON.tmp" && mv "$META_JSON.tmp" "$META_JSON" + --arg dns_name "$DNS_NAME" \ + '.meta.pid=$pid | .meta.created_at=$created_at | .meta.dns_name=$dns_name | del(.meta.dns_file)' \ + "$VM_JSON" > "$VM_JSON.tmp" && mv "$VM_JSON.tmp" "$VM_JSON" VM_CONFIG_JSON="$(/usr/bin/sudo /usr/bin/curl --unix-socket "$API_SOCK" -sS http://localhost/vm/config)" -jq '.' <<<"$VM_CONFIG_JSON" > "$VM_JSON" +jq \ + --argjson config "$VM_CONFIG_JSON" \ + '.config=$config' \ + "$VM_JSON" > "$VM_JSON.tmp" && mv "$VM_JSON.tmp" "$VM_JSON" +VM_STARTED=1 log "restored" diff --git a/rm.sh b/rm.sh index 6061090..8e38197 100755 --- a/rm.sh +++ b/rm.sh @@ -5,9 +5,14 @@ log() { printf '[rm] %s\n' "$*" } +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/dns.sh" +STATE="$DIR/state" +VM_ROOT="$STATE/vms" + usage() { cat <<'EOF' -Usage: ./rm.sh +Usage: ./rm.sh ... Removes VM artifacts from state/ and cleans up TAP and mapped devices. EOF @@ -17,7 +22,7 @@ find_vm_json() { local query="$1" local vm_json match_count=0 match="" - for vm_json in state/vms/*/vm.json; do + for vm_json in "$VM_ROOT"/*/vm.json; do [[ -f "$vm_json" ]] || continue local id name id="$(jq -r '.meta.id // empty' "$vm_json")" @@ -40,42 +45,69 @@ find_vm_json() { printf '%s' "$match" } -QUERY="${1:-}" -if [[ -z "$QUERY" || "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then +QUERIES=("$@") +if (( ${#QUERIES[@]} == 1 )) && [[ "${QUERIES[0]}" == "-h" || "${QUERIES[0]}" == "--help" ]]; then + usage + exit 0 +fi +if (( ${#QUERIES[@]} == 0 )); then usage exit 1 fi -VM_JSON="$(find_vm_json "$QUERY")" -VM_DIR="$(dirname "$VM_JSON")" -PID="$(jq -r '.meta.pid // empty' "$VM_JSON")" -TAP="$(jq -r '.meta.tap // empty' "$VM_JSON")" -API_SOCK="$(jq -r '.meta.api_sock // empty' "$VM_JSON")" -BASE_LOOP="$(jq -r '.meta.base_loop // empty' "$VM_JSON")" -COW_LOOP="$(jq -r '.meta.cow_loop // empty' "$VM_JSON")" -DM_DEV="$(jq -r '.meta.dm_dev // empty' "$VM_JSON")" -DM_NAME="$(jq -r '.meta.dm_name // empty' "$VM_JSON")" +VM_JSONS=() +for query in "${QUERIES[@]}"; do + VM_JSON="$(find_vm_json "$query")" + already_added=0 + for existing_vm_json in "${VM_JSONS[@]}"; do + if [[ "$existing_vm_json" == "$VM_JSON" ]]; then + already_added=1 + break + fi + done + if (( already_added == 0 )); then + VM_JSONS+=("$VM_JSON") + fi +done -if [[ -n "$PID" ]]; then - sudo kill "$PID" 2>/dev/null || true -fi -if [[ -n "$TAP" ]]; then - sudo ip link del "$TAP" 2>/dev/null || true -fi -if [[ -n "$API_SOCK" ]]; then - rm -f "$API_SOCK" -fi -if [[ -n "$DM_DEV" || -n "$DM_NAME" ]]; then - sudo dmsetup remove "${DM_NAME:-$DM_DEV}" 2>/dev/null || true -fi -if [[ -n "$COW_LOOP" ]]; then - sudo losetup -d "$COW_LOOP" 2>/dev/null || true -fi -if [[ -n "$BASE_LOOP" ]]; then - sudo losetup -d "$BASE_LOOP" 2>/dev/null || true -fi -if [[ -d "$VM_DIR" ]]; then - rm -rf "$VM_DIR" -fi +for vm_json in "${VM_JSONS[@]}"; do + vm_dir="$(dirname "$vm_json")" + vm_id="$(jq -r '.meta.id // empty' "$vm_json")" + vm_name="$(jq -r '.meta.name // empty' "$vm_json")" + pid="$(jq -r '.meta.pid // empty' "$vm_json")" + tap="$(jq -r '.meta.tap // empty' "$vm_json")" + api_sock="$(jq -r '.meta.api_sock // empty' "$vm_json")" + base_loop="$(jq -r '.meta.base_loop // empty' "$vm_json")" + cow_loop="$(jq -r '.meta.cow_loop // empty' "$vm_json")" + dm_dev="$(jq -r '.meta.dm_dev // empty' "$vm_json")" + dm_name="$(jq -r '.meta.dm_name // empty' "$vm_json")" + dns_name="$(jq -r '.meta.dns_name // empty' "$vm_json")" + if [[ -z "$dns_name" ]]; then + dns_name="$(banger_dns_name "$vm_name")" + fi -log "removed $VM_DIR" + if [[ -n "$pid" ]]; then + sudo kill "$pid" 2>/dev/null || true + fi + if [[ -n "$tap" ]]; then + sudo ip link del "$tap" 2>/dev/null || true + fi + if [[ -n "$api_sock" ]]; then + rm -f "$api_sock" + fi + banger_dns_remove_record_name "$dns_name" + if [[ -n "$dm_dev" || -n "$dm_name" ]]; then + sudo dmsetup remove "${dm_name:-$dm_dev}" 2>/dev/null || true + fi + if [[ -n "$cow_loop" ]]; then + sudo losetup -d "$cow_loop" 2>/dev/null || true + fi + if [[ -n "$base_loop" ]]; then + sudo losetup -d "$base_loop" 2>/dev/null || true + fi + if [[ -d "$vm_dir" ]]; then + rm -rf "$vm_dir" + fi + + log "removed ${vm_name:-$vm_dir}" +done diff --git a/run.sh b/run.sh index 22c58ee..45e84b4 100755 --- a/run.sh +++ b/run.sh @@ -25,6 +25,7 @@ EOF log "starting" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/dns.sh" STATE="$DIR/state" VM_ROOT="$STATE/vms" mkdir -p "$VM_ROOT" @@ -254,6 +255,41 @@ BASE_LOOP="" COW_LOOP="" DM_NAME="fc-rootfs-$VM_TAG" DM_DEV="" +SEED_ROOT_MNT="$VM_DIR/mnt-root" +SEED_HOME_MNT="$VM_DIR/mnt-home" +SEED_VAR_MNT="$VM_DIR/mnt-var" +DNS_NAME="" + +unmount_if_needed() { + local mount_path="$1" + [[ -n "$mount_path" ]] || return 0 + sudo umount "$mount_path" 2>/dev/null || true +} + +seed_volume_from_rootfs() { + local source_dir="$1" + local target_image="$2" + local target_mount="$3" + local label="$4" + + if [[ ! -d "$source_dir" ]]; then + log "source directory missing in rootfs snapshot: $label" + exit 1 + fi + + mkdir -p "$target_mount" + sudo mount "$target_image" "$target_mount" + sudo cp -a "$source_dir/." "$target_mount/" + sudo umount "$target_mount" +} + +populate_data_disks() { + mkdir -p "$SEED_ROOT_MNT" "$SEED_HOME_MNT" "$SEED_VAR_MNT" + sudo mount -o ro "$DM_DEV" "$SEED_ROOT_MNT" + seed_volume_from_rootfs "$SEED_ROOT_MNT/home" "$HOME_PATH" "$SEED_HOME_MNT" "/home" + seed_volume_from_rootfs "$SEED_ROOT_MNT/var" "$VAR_PATH" "$SEED_VAR_MNT" "/var" + sudo umount "$SEED_ROOT_MNT" +} cleanup() { local exit_code=$? @@ -268,6 +304,10 @@ cleanup() { sudo ip link del "$TAP_DEV" 2>/dev/null || true fi rm -f "${API_SOCK:-}" + unmount_if_needed "${SEED_VAR_MNT:-}" + unmount_if_needed "${SEED_HOME_MNT:-}" + unmount_if_needed "${SEED_ROOT_MNT:-}" + banger_dns_remove_record_name "${DNS_NAME:-}" if [[ -n "${DM_NAME:-}" ]]; then sudo dmsetup remove "$DM_NAME" 2>/dev/null || true fi @@ -328,6 +368,10 @@ if ! command -v e2cp >/dev/null 2>&1 || ! command -v e2rm >/dev/null 2>&1; then log "e2cp and e2rm are required to set hostname and resolv.conf" exit 1 fi +if ! command -v mount >/dev/null 2>&1 || ! command -v umount >/dev/null 2>&1; then + log "mount and umount are required to populate home/var disks" + exit 1 +fi if ! command -v jq >/dev/null 2>&1; then log "jq is required to persist VM metadata" exit 1 @@ -368,6 +412,9 @@ sudo e2cp "$HOSTS_TMP" "$DM_DEV:/etc/hosts" >/dev/null 2>&1 || { exit 1 } +log "populating /home and /var disks from rootfs snapshot" +populate_data_disks + # Host bridge if ! ip link show "$BR_DEV" >/dev/null 2>&1; then log "creating host bridge $BR_DEV ($BR_IP/$CIDR)" @@ -494,9 +541,10 @@ log "starting virtual machine" "${CURL_CMD[@]}" --unix-socket "$API_SOCK" -X PUT http://localhost/actions \ -H "Content-Type: application/json" \ -d '{ "action_type": "InstanceStart" }' >/dev/null -VM_STARTED=1 VM_CONFIG_JSON="$("${CURL_CMD[@]}" --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" \ @@ -515,9 +563,11 @@ jq -n \ --arg cow_loop "$COW_LOOP" \ --arg dm_name "$DM_NAME" \ --arg dm_dev "$DM_DEV" \ + --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,home_path:$home_path,var_path:$var_path,base_loop:$base_loop,cow_file:$cow_file,cow_loop:$cow_loop,dm_name:$dm_name,dm_dev:$dm_dev},config:$config}' \ + '{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,home_path:$home_path,var_path:$var_path,base_loop:$base_loop,cow_file:$cow_file,cow_loop:$cow_loop,dm_name:$dm_name,dm_dev:$dm_dev,dns_name:$dns_name},config:$config}' \ > "$VM_DIR/vm.json" +VM_STARTED=1 log "vm started successfully" log "guest ip: $GUEST_IP" diff --git a/ssh.sh b/ssh.sh new file mode 100755 index 0000000..6990e92 --- /dev/null +++ b/ssh.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[ssh] %s\n' "$*" +} + +usage() { + cat <<'EOF' +Usage: ./ssh.sh [ssh-args...] + +Resolves a running VM by name, guest IP, or ID prefix and opens SSH as root. +EOF +} + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE="$DIR/state" +VM_ROOT="$STATE/vms" +SSH_KEY="$DIR/id_ed25519" + +find_vm_json() { + local query="$1" + local vm_json exact_match="" prefix_match="" + local exact_count=0 prefix_count=0 + + for vm_json in "$VM_ROOT"/*/vm.json; do + [[ -f "$vm_json" ]] || continue + + local id name guest_ip + id="$(jq -r '.meta.id // empty' "$vm_json")" + name="$(jq -r '.meta.name // empty' "$vm_json")" + guest_ip="$(jq -r '.meta.guest_ip // empty' "$vm_json")" + + if [[ "$guest_ip" == "$query" || "$name" == "$query" || "$id" == "$query" ]]; then + exact_match="$vm_json" + exact_count=$((exact_count + 1)) + continue + fi + + if [[ "$name" == "$query"* || "$id" == "$query"* ]]; then + prefix_match="$vm_json" + prefix_count=$((prefix_count + 1)) + fi + done + + if (( exact_count == 1 )); then + printf '%s' "$exact_match" + return 0 + fi + if (( exact_count > 1 )); then + log "multiple VMs found for: $query" + exit 1 + fi + if (( prefix_count == 1 )); then + printf '%s' "$prefix_match" + return 0 + fi + if (( prefix_count > 1 )); then + log "multiple VMs found for prefix: $query" + exit 1 + fi + + log "no VM found for: $query" + exit 1 +} + +vm_is_running() { + local vm_json="$1" + local pid api_sock + pid="$(jq -r '.meta.pid // empty' "$vm_json")" + api_sock="$(jq -r '.meta.api_sock // empty' "$vm_json")" + + [[ -n "$pid" && -n "$api_sock" ]] || return 1 + ps -p "$pid" -o comm=,args= 2>/dev/null | rg -q "firecracker.*--api-sock $api_sock" +} + +QUERY="${1:-}" +if [[ "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then + usage + exit 0 +fi +if [[ -z "$QUERY" ]]; then + usage + exit 1 +fi +shift + +if [[ ! -f "$SSH_KEY" ]]; then + log "ssh key not found: $SSH_KEY" + exit 1 +fi + +VM_JSON="$(find_vm_json "$QUERY")" +if ! vm_is_running "$VM_JSON"; then + log "vm is not running: $QUERY" + exit 1 +fi + +GUEST_IP="$(jq -r '.meta.guest_ip // empty' "$VM_JSON")" +VM_NAME="$(jq -r '.meta.name // empty' "$VM_JSON")" +if [[ -z "$GUEST_IP" ]]; then + log "guest IP not found for: $QUERY" + exit 1 +fi + +log "connecting to $VM_NAME ($GUEST_IP)" +exec ssh \ + -i "$SSH_KEY" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + "root@$GUEST_IP" \ + "$@" diff --git a/stop.sh b/stop.sh index d50a99f..01f2f37 100755 --- a/stop.sh +++ b/stop.sh @@ -5,9 +5,14 @@ log() { printf '[stop] %s\n' "$*" } +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/dns.sh" +STATE="$DIR/state" +VM_ROOT="$STATE/vms" + usage() { cat <<'EOF' -Usage: ./stop.sh +Usage: ./stop.sh ... Sends Ctrl+Alt+Del to the guest via the Firecracker API socket. EOF @@ -17,7 +22,7 @@ find_vm_json() { local query="$1" local vm_json match_count=0 match="" - for vm_json in state/vms/*/vm.json; do + for vm_json in "$VM_ROOT"/*/vm.json; do [[ -f "$vm_json" ]] || continue local id name id="$(jq -r '.meta.id // empty' "$vm_json")" @@ -40,21 +45,64 @@ find_vm_json() { printf '%s' "$match" } -QUERY="${1:-}" -if [[ -z "$QUERY" || "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then +QUERIES=("$@") +if (( ${#QUERIES[@]} == 1 )) && [[ "${QUERIES[0]}" == "-h" || "${QUERIES[0]}" == "--help" ]]; then + usage + exit 0 +fi +if (( ${#QUERIES[@]} == 0 )); then usage exit 1 fi -VM_JSON="$(find_vm_json "$QUERY")" -API_SOCK="$(jq -r '.meta.api_sock // empty' "$VM_JSON")" -if [[ -z "$API_SOCK" || ! -S "$API_SOCK" ]]; then - log "api socket not found: $API_SOCK" - exit 1 -fi +VM_JSONS=() +for query in "${QUERIES[@]}"; do + VM_JSON="$(find_vm_json "$query")" + already_added=0 + for existing_vm_json in "${VM_JSONS[@]}"; do + if [[ "$existing_vm_json" == "$VM_JSON" ]]; then + already_added=1 + break + fi + done + if (( already_added == 0 )); then + VM_JSONS+=("$VM_JSON") + fi +done -log "sending Ctrl+Alt+Del to guest" -sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/actions \ - -H "Content-Type: application/json" \ - -d '{ "action_type": "SendCtrlAltDel" }' >/dev/null -log "requested shutdown" +for vm_json in "${VM_JSONS[@]}"; do + vm_id="$(jq -r '.meta.id // empty' "$vm_json")" + vm_name="$(jq -r '.meta.name // empty' "$vm_json")" + pid="$(jq -r '.meta.pid // empty' "$vm_json")" + tap="$(jq -r '.meta.tap // empty' "$vm_json")" + api_sock="$(jq -r '.meta.api_sock // empty' "$vm_json")" + base_loop="$(jq -r '.meta.base_loop // empty' "$vm_json")" + cow_loop="$(jq -r '.meta.cow_loop // empty' "$vm_json")" + dm_dev="$(jq -r '.meta.dm_dev // empty' "$vm_json")" + dm_name="$(jq -r '.meta.dm_name // empty' "$vm_json")" + dns_name="$(jq -r '.meta.dns_name // empty' "$vm_json")" + if [[ -z "$dns_name" ]]; then + dns_name="$(banger_dns_name "$vm_name")" + fi + if [[ -z "$pid" ]]; then + log "pid not found for ${vm_name:-$vm_json}" + exit 1 + fi + if [[ -z "$api_sock" || ! -S "$api_sock" ]]; then + log "api socket not found for ${vm_name:-$vm_json}: $api_sock" + exit 1 + fi + + log "sending Ctrl+Alt+Del to ${vm_name:-$vm_json}" + sudo -E curl --unix-socket "$api_sock" -X PUT http://localhost/actions \ + -H "Content-Type: application/json" \ + -d '{ "action_type": "SendCtrlAltDel" }' >/dev/null + if ! banger_wait_for_vm_exit "$pid" "$api_sock" 30; then + log "timed out waiting for ${vm_name:-$vm_json} to exit" + exit 1 + fi + banger_teardown_vm_runtime "$tap" "$api_sock" "$dm_name" "$dm_dev" "$cow_loop" "$base_loop" + banger_mark_vm_stopped "$vm_json" + banger_dns_remove_record_name "$dns_name" + log "requested shutdown for ${vm_name:-$vm_json}" +done