Store VM metadata as JSON

This commit is contained in:
Thales Maciel 2026-01-31 23:17:12 -03:00
parent bbd57d8dd2
commit 7af04b7535
No known key found for this signature in database
GPG key ID: 33112E6833C34679
11 changed files with 188 additions and 178 deletions

View file

@ -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/<id>/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/<id>/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`.

View file

@ -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" <<EOF
id=$VM_ID
name=$VM_NAME
pid=$FC_PID
created_at=$(date -Iseconds)
guest_ip=$GUEST_IP
tap=$TAP_DEV
api_sock=$API_SOCK
log=$LOG_FILE
EOF
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 \
@ -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

30
kill.sh
View file

@ -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

55
list.sh
View file

@ -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

24
logs.sh
View file

@ -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"

28
nat.sh
View file

@ -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

22
ps.sh
View file

@ -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

38
rm.sh
View file

@ -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

51
run.sh
View file

@ -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" <<EOF
id=$VM_ID
name=$VM_NAME
pid=$FC_PID
created_at=$(date -Iseconds)
rootfs=$ROOTFS
kernel=$KERNEL
guest_ip=$GUEST_IP
tap=$TAP_DEV
api_sock=$API_SOCK
log=$LOG_FILE
base_loop=$BASE_LOOP
cow_file=$COW_FILE
cow_loop=$COW_LOOP
dm_name=$DM_NAME
dm_dev=$DM_DEV
EOF
VM_CONFIG_JSON="$("${CURL_CMD[@]}" --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 "$ROOTFS" \
--arg kernel "$KERNEL" \
--arg home_path "$HOME_PATH" \
--arg var_path "$VAR_PATH" \
--arg base_loop "$BASE_LOOP" \
--arg cow_file "$COW_FILE" \
--arg cow_loop "$COW_LOOP" \
--arg dm_name "$DM_NAME" \
--arg dm_dev "$DM_DEV" \
--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}' \
> "$VM_DIR/vm.json"
log "vm started successfully"
log "guest ip: $GUEST_IP"

24
stop.sh
View file

@ -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

View file

@ -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