From 7af04b7535b28b128579c4a099e7c19e68101ea6 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 31 Jan 2026 23:17:12 -0300 Subject: [PATCH] Store VM metadata as JSON --- README.md | 21 ++++---------------- customize.sh | 43 ++++++++++++++++++++++++++++++---------- kill.sh | 30 ++++++++++++---------------- list.sh | 55 +++++++++++++++++++++++++++++++++++++--------------- logs.sh | 24 +++++++++-------------- nat.sh | 28 +++++++++++--------------- ps.sh | 22 ++++++++------------- rm.sh | 38 +++++++++++++++--------------------- run.sh | 51 ++++++++++++++++++++++++++++-------------------- stop.sh | 24 +++++++++-------------- verify.sh | 30 +++++++++++++++------------- 11 files changed, 188 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 61f6657..887772c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Minimal Firecracker launcher. ## Requirements - Linux host with KVM (`/dev/kvm` access) -- `sudo`, `ip`, `curl`, `ssh` +- `sudo`, `ip`, `curl`, `ssh`, `jq` - `dmsetup`, `losetup`, `blockdev` (device-mapper snapshot for rootfs) - `e2cp`, `e2rm` (writes hostname and resolv.conf into rootfs snapshot) @@ -63,22 +63,9 @@ reboot ``` ## VM Info File -Each VM writes a metadata file at `state/vms//info` with the following fields: -- `id`: unique identifier for the VM instance. -- `name`: VM name. -- `pid`: Firecracker process ID. -- `created_at`: timestamp when the VM was launched. -- `rootfs`: root filesystem image path used to launch the VM. -- `kernel`: kernel image path used to launch the VM. -- `guest_ip`: IP address assigned to the guest. -- `tap`: host TAP interface name attached to the bridge. -- `api_sock`: path to the Firecracker API socket (stored under `$XDG_RUNTIME_DIR/banger/` when available). -- `log`: path to the Firecracker log file. -- `base_loop`: loop device backing the base rootfs (if present). -- `cow_file`: copy-on-write image file (if present). -- `cow_loop`: loop device for the COW image (if present). -- `dm_name`: device-mapper name for the merged rootfs (if present). -- `dm_dev`: device-mapper device path for the merged rootfs (if present). +Each VM writes `state/vms//vm.json` with: +- `meta`: local metadata (id, name, pid, created_at, guest_ip, tap, api_sock, log, rootfs, kernel, snapshot info). +- `config`: full `/vm/config` response from Firecracker. ## 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 d9f6ee0..43ce367 100755 --- a/customize.sh +++ b/customize.sh @@ -89,6 +89,10 @@ 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 log "copying base rootfs to $OUT_ROOTFS" cp --reflink=auto "$BASE_ROOTFS" "$OUT_ROOTFS" @@ -165,16 +169,22 @@ for _ in $(seq 1 200); do done [[ -S "$API_SOCK" ]] || { log "firecracker api socket not ready"; exit 1; } -cat > "$VM_DIR/info" < "$VM_DIR/vm.json" log "configuring machine" sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/machine-config \ @@ -232,6 +242,19 @@ ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ 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 +mkdir -p /home /var +if ! grep -q '^/dev/vdb ' /etc/fstab; then + echo '/dev/vdb /home ext4 defaults 0 2' >> /etc/fstab +fi +if ! grep -q '^/dev/vdc ' /etc/fstab; then + echo '/dev/vdc /var ext4 defaults 0 2' >> /etc/fstab +fi +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 git less tree ca-certificates curl diff --git a/kill.sh b/kill.sh index d03a30b..9ee2081 100755 --- a/kill.sh +++ b/kill.sh @@ -13,23 +13,17 @@ Sends a signal to the Firecracker process. EOF } -get_prop() { - local info="$1" - local key="$2" - awk -F= -v k="$key" '$1==k {print $2}' "$info" -} - -find_vm_info() { +find_vm_json() { local query="$1" - local info match_count=0 match="" + local vm_json match_count=0 match="" - for info in state/vms/*/info; do - [[ -f "$info" ]] || continue + for vm_json in state/vms/*/vm.json; do + [[ -f "$vm_json" ]] || continue local id name - id="$(get_prop "$info" "id")" - name="$(get_prop "$info" "name")" + id="$(jq -r '.meta.id // empty' "$vm_json")" + name="$(jq -r '.meta.name // empty' "$vm_json")" if [[ "$id" == "$query"* || "$name" == "$query"* ]]; then - match="$info" + match="$vm_json" match_count=$((match_count + 1)) fi done @@ -75,15 +69,15 @@ if [[ -z "$QUERY" || "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then exit 1 fi -INFO_FILE="$(find_vm_info "$QUERY")" -PID="$(get_prop "$INFO_FILE" "pid")" -API_SOCK="$(get_prop "$INFO_FILE" "api_sock")" +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 $INFO_FILE" + log "pid not found in $VM_JSON" exit 1 fi if [[ -z "$API_SOCK" ]]; then - log "api_sock not found in $INFO_FILE" + log "api_sock not found in $VM_JSON" exit 1 fi diff --git a/list.sh b/list.sh index cce4012..8342c6c 100755 --- a/list.sh +++ b/list.sh @@ -3,28 +3,51 @@ set -euo pipefail shopt -s nullglob -get_prop() { - local info="$1" - local key="$2" - awk -F= -v k="$key" '$1==k {print $2}' "$info" -} +statuses=() +ids=() +names=() +ips=() +created=() +max_name=4 +max_ip=2 +GREEN='\033[32m' +YELLOW='\033[33m' +RESET='\033[0m' -for info in state/vms/*/info; do - id="$(get_prop "$info" "id")" - name="$(get_prop "$info" "name")" - created_at="$(get_prop "$info" "created_at")" - guest_ip="$(get_prop "$info" "guest_ip")" - pid="$(get_prop "$info" "pid")" - tap="$(get_prop "$info" "tap")" - api_sock="$(get_prop "$info" "api_sock")" +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")" + 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")" - status="stale" + status="stopped" if [[ -n "$pid" && -n "$api_sock" ]]; then if ps -p "$pid" -o comm=,args= 2>/dev/null | rg -q "firecracker.*--api-sock $api_sock"; then status="running" fi fi - printf 'status=%s id=%s name=%s created_at=%s guest_ip=%s pid=%s tap=%s\n' \ - "$status" "$id" "$name" "$created_at" "$guest_ip" "$pid" "$tap" + short_id="${id:0:12}" + statuses+=("$status") + ids+=("$short_id") + names+=("$name") + ips+=("$guest_ip") + created+=("$created_at") + if (( ${#name} > max_name )); then + max_name=${#name} + fi + if (( ${#guest_ip} > max_ip )); then + max_ip=${#guest_ip} + fi +done + +for i in "${!ids[@]}"; do + color="$YELLOW" + 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]}" done diff --git a/logs.sh b/logs.sh index 75d2b9a..e685e50 100755 --- a/logs.sh +++ b/logs.sh @@ -13,23 +13,17 @@ Prints the Firecracker log for a VM. Use --follow to tail -f. EOF } -get_prop() { - local info="$1" - local key="$2" - awk -F= -v k="$key" '$1==k {print $2}' "$info" -} - -find_vm_info() { +find_vm_json() { local query="$1" - local info match_count=0 match="" + local vm_json match_count=0 match="" - for info in state/vms/*/info; do - [[ -f "$info" ]] || continue + for vm_json in state/vms/*/vm.json; do + [[ -f "$vm_json" ]] || continue local id name - id="$(get_prop "$info" "id")" - name="$(get_prop "$info" "name")" + id="$(jq -r '.meta.id // empty' "$vm_json")" + name="$(jq -r '.meta.name // empty' "$vm_json")" if [[ "$id" == "$query"* || "$name" == "$query"* ]]; then - match="$info" + match="$vm_json" match_count=$((match_count + 1)) fi done @@ -76,8 +70,8 @@ if [[ -z "$QUERY" ]]; then exit 1 fi -INFO_FILE="$(find_vm_info "$QUERY")" -LOG_FILE="$(get_prop "$INFO_FILE" "log")" +VM_JSON="$(find_vm_json "$QUERY")" +LOG_FILE="$(jq -r '.meta.log // empty' "$VM_JSON")" if [[ -z "$LOG_FILE" || ! -f "$LOG_FILE" ]]; then log "log file not found: $LOG_FILE" diff --git a/nat.sh b/nat.sh index 8923eb8..45ab76d 100755 --- a/nat.sh +++ b/nat.sh @@ -13,23 +13,17 @@ Manage per-VM NAT rules for internet access. EOF } -get_prop() { - local info="$1" - local key="$2" - awk -F= -v k="$key" '$1==k {print $2}' "$info" -} - -find_vm_info() { +find_vm_json() { local query="$1" - local info match_count=0 match="" + local vm_json match_count=0 match="" - for info in state/vms/*/info; do - [[ -f "$info" ]] || continue + for vm_json in state/vms/*/vm.json; do + [[ -f "$vm_json" ]] || continue local id name - id="$(get_prop "$info" "id")" - name="$(get_prop "$info" "name")" + id="$(jq -r '.meta.id // empty' "$vm_json")" + name="$(jq -r '.meta.name // empty' "$vm_json")" if [[ "$id" == "$query"* || "$name" == "$query"* ]]; then - match="$info" + match="$vm_json" match_count=$((match_count + 1)) fi done @@ -64,12 +58,12 @@ if [[ -z "$ACTION" || -z "$QUERY" ]]; then exit 1 fi -INFO_FILE="$(find_vm_info "$QUERY")" -GUEST_IP="$(get_prop "$INFO_FILE" "guest_ip")" -TAP="$(get_prop "$INFO_FILE" "tap")" +VM_JSON="$(find_vm_json "$QUERY")" +GUEST_IP="$(jq -r '.meta.guest_ip // empty' "$VM_JSON")" +TAP="$(jq -r '.meta.tap // empty' "$VM_JSON")" if [[ -z "$GUEST_IP" || -z "$TAP" ]]; then - log "missing guest_ip or tap in $INFO_FILE" + log "missing guest_ip or tap in $VM_JSON" exit 1 fi diff --git a/ps.sh b/ps.sh index dbc1a2a..b486872 100755 --- a/ps.sh +++ b/ps.sh @@ -3,20 +3,14 @@ set -euo pipefail shopt -s nullglob -get_prop() { - local info="$1" - local key="$2" - awk -F= -v k="$key" '$1==k {print $2}' "$info" -} - -for info in state/vms/*/info; do - id="$(get_prop "$info" "id")" - name="$(get_prop "$info" "name")" - created_at="$(get_prop "$info" "created_at")" - guest_ip="$(get_prop "$info" "guest_ip")" - pid="$(get_prop "$info" "pid")" - tap="$(get_prop "$info" "tap")" - api_sock="$(get_prop "$info" "api_sock")" +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")" + guest_ip="$(jq -r '.meta.guest_ip // 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")" if [[ -z "$pid" || -z "$api_sock" ]]; then continue diff --git a/rm.sh b/rm.sh index 737d60b..6061090 100755 --- a/rm.sh +++ b/rm.sh @@ -13,23 +13,17 @@ Removes VM artifacts from state/ and cleans up TAP and mapped devices. EOF } -get_prop() { - local info="$1" - local key="$2" - awk -F= -v k="$key" '$1==k {print $2}' "$info" -} - -find_vm_info() { +find_vm_json() { local query="$1" - local info match_count=0 match="" + local vm_json match_count=0 match="" - for info in state/vms/*/info; do - [[ -f "$info" ]] || continue + for vm_json in state/vms/*/vm.json; do + [[ -f "$vm_json" ]] || continue local id name - id="$(get_prop "$info" "id")" - name="$(get_prop "$info" "name")" + id="$(jq -r '.meta.id // empty' "$vm_json")" + name="$(jq -r '.meta.name // empty' "$vm_json")" if [[ "$id" == "$query"* || "$name" == "$query"* ]]; then - match="$info" + match="$vm_json" match_count=$((match_count + 1)) fi done @@ -52,15 +46,15 @@ if [[ -z "$QUERY" || "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then exit 1 fi -INFO_FILE="$(find_vm_info "$QUERY")" -VM_DIR="${INFO_FILE%/info}" -PID="$(get_prop "$INFO_FILE" "pid")" -TAP="$(get_prop "$INFO_FILE" "tap")" -API_SOCK="$(get_prop "$INFO_FILE" "api_sock")" -BASE_LOOP="$(get_prop "$INFO_FILE" "base_loop")" -COW_LOOP="$(get_prop "$INFO_FILE" "cow_loop")" -DM_DEV="$(get_prop "$INFO_FILE" "dm_dev")" -DM_NAME="$(get_prop "$INFO_FILE" "dm_name")" +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")" if [[ -n "$PID" ]]; then sudo kill "$PID" 2>/dev/null || true diff --git a/run.sh b/run.sh index 685290d..5783393 100755 --- a/run.sh +++ b/run.sh @@ -62,9 +62,9 @@ shopt -s nullglob name_taken() { local candidate="$1" - local info existing_name - for info in "$VM_ROOT"/*/info; do - existing_name="$(awk -F= '$1=="name"{print $2}' "$info")" + local vm_json existing_name + for vm_json in "$VM_ROOT"/*/vm.json; do + existing_name="$(jq -r '.meta.name // empty' "$vm_json" 2>/dev/null)" if [[ "$existing_name" == "$candidate" ]]; then return 0 fi @@ -311,6 +311,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 jq >/dev/null 2>&1; then + log "jq is required to persist VM metadata" + exit 1 +fi COW_BYTES="$(parse_disk_size "$COW_SIZE")" if [[ -z "$COW_BYTES" ]]; then @@ -469,24 +473,29 @@ log "starting virtual machine" -H "Content-Type: application/json" \ -d '{ "action_type": "InstanceStart" }' >/dev/null VM_STARTED=1 - -cat > "$VM_DIR/info" < "$VM_DIR/vm.json" log "vm started successfully" log "guest ip: $GUEST_IP" diff --git a/stop.sh b/stop.sh index 41c53fc..d50a99f 100755 --- a/stop.sh +++ b/stop.sh @@ -13,23 +13,17 @@ Sends Ctrl+Alt+Del to the guest via the Firecracker API socket. EOF } -get_prop() { - local info="$1" - local key="$2" - awk -F= -v k="$key" '$1==k {print $2}' "$info" -} - -find_vm_info() { +find_vm_json() { local query="$1" - local info match_count=0 match="" + local vm_json match_count=0 match="" - for info in state/vms/*/info; do - [[ -f "$info" ]] || continue + for vm_json in state/vms/*/vm.json; do + [[ -f "$vm_json" ]] || continue local id name - id="$(get_prop "$info" "id")" - name="$(get_prop "$info" "name")" + id="$(jq -r '.meta.id // empty' "$vm_json")" + name="$(jq -r '.meta.name // empty' "$vm_json")" if [[ "$id" == "$query"* || "$name" == "$query"* ]]; then - match="$info" + match="$vm_json" match_count=$((match_count + 1)) fi done @@ -52,8 +46,8 @@ if [[ -z "$QUERY" || "$QUERY" == "-h" || "$QUERY" == "--help" ]]; then exit 1 fi -INFO_FILE="$(find_vm_info "$QUERY")" -API_SOCK="$(get_prop "$INFO_FILE" "api_sock")" +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 diff --git a/verify.sh b/verify.sh index 22bfc04..29913b0 100755 --- a/verify.sh +++ b/verify.sh @@ -6,18 +6,19 @@ log() { } cleanup() { - if [[ -z "${VM_INFO:-}" || ! -f "$VM_INFO" ]]; then + if [[ -z "${VM_JSON:-}" || ! -f "$VM_JSON" ]]; then return fi - # shellcheck disable=SC1090 - source "$VM_INFO" - if [[ -n "${pid:-}" ]]; then + pid="$(jq -r '.meta.pid // empty' "$VM_JSON")" + tap="$(jq -r '.meta.tap // empty' "$VM_JSON")" + vm_dir="$(dirname "$VM_JSON")" + if [[ -n "$pid" ]]; then sudo kill "$pid" 2>/dev/null || true fi - if [[ -n "${tap:-}" ]]; then + if [[ -n "$tap" ]]; then sudo ip link del "$tap" 2>/dev/null || true fi - if [[ -n "${vm_dir:-}" ]]; then + if [[ -n "$vm_dir" ]]; then rm -rf "$vm_dir" fi } @@ -35,18 +36,21 @@ if [[ -z "$VM_DIR" ]]; then log "no VM state directory found" exit 1 fi -VM_INFO="$VM_DIR/info" -if [[ ! -f "$VM_INFO" ]]; then - log "info file not found: $VM_INFO" +VM_JSON="$VM_DIR/vm.json" +if [[ ! -f "$VM_JSON" ]]; then + log "vm.json not found: $VM_JSON" exit 1 fi -# shellcheck disable=SC1090 -source "$VM_INFO" +name="$(jq -r '.meta.name // empty' "$VM_JSON")" +created_at="$(jq -r '.meta.created_at // empty' "$VM_JSON")" +guest_ip="$(jq -r '.meta.guest_ip // empty' "$VM_JSON")" +tap="$(jq -r '.meta.tap // empty' "$VM_JSON")" +pid="$(jq -r '.meta.pid // empty' "$VM_JSON")" vm_dir="$VM_DIR" -if [[ -z "${name:-}" || -z "${created_at:-}" || -z "${guest_ip:-}" ]]; then - log "missing name or created_at in info file" +if [[ -z "$name" || -z "$created_at" || -z "$guest_ip" ]]; then + log "missing name or created_at in vm.json" exit 1 fi