#!/usr/bin/env -S nix shell nixpkgs#bash --command bash # # vm.sh - Incus VM launcher for musicfs-server # # Creates and manages an Incus VM that runs the `musicfs-server` gRPC binary, # live-sharing the host's ~/Music via a virtiofs disk device. # # The server binary is built on the host (cargo) and shipped into the VM as a # *self-contained bundle*: when the host toolchain is nix-based (devenv), the # resulting ELF interpreter points at /nix/store/.../ld-linux-x86-64.so.2 which # does not exist inside a stock Debian/Ubuntu VM. This script detects that, # retargets the interpreter + rpath to an absolute in-VM path via patchelf, and # bundles the few required shared objects alongside the binary. The VM therefore # needs zero package installs to run the server. # # Usage: # scripts/vm.sh up create VM (if missing), share music, build+ship server, start it # scripts/vm.sh redeploy rebuild + push + restart server (VM kept running) # scripts/vm.sh restart restart the in-VM systemd service (no rebuild) # scripts/vm.sh logs tail server logs (journalctl -f) # scripts/vm.sh status show VM + service status # scripts/vm.sh shell open a shell in the VM # scripts/vm.sh down stop the VM # scripts/vm.sh destroy delete the VM (and its disk) permanently # # Configuration (override via env, e.g. IMAGE=images:ubuntu/24.04 scripts/vm.sh up): # VM_NAME name of the Incus instance (default: musicfs) # IMAGE Incus image alias (default: images:debian/12) # MUSIC_SOURCE host path to the music library (default: $HOME/Music) # MUSIC_MOUNT where it appears inside the VM (default: /music) # LISTEN_PORT gRPC port (host:vm forwarded) (default: 50051) # RUST_LOG tracing filter for the server (default: info) # READONLY mount music as read-only (0=writable) (default: 1) # VM_IP static IPv4 for the VM (default: auto-detected from incusbr0, gateway.145) # set -euo pipefail # ─────────────────────────────── config ──────────────────────────────── VM_NAME="${VM_NAME:-musicfs}" IMAGE="${IMAGE:-images:debian/12}" MUSIC_SOURCE="${MUSIC_SOURCE:-$HOME/Music}" MUSIC_MOUNT="${MUSIC_MOUNT:-/music}" LISTEN_PORT="${LISTEN_PORT:-50051}" RUST_LOG="${RUST_LOG:-info}" detect_static_ip() { if [[ -n "${VM_IP:-}" ]]; then echo "$VM_IP"; return; fi local bridge_cidr bridge_cidr="$(incus network get incusbr0 ipv4.address 2>/dev/null || true)" if [[ -z "$bridge_cidr" ]]; then echo ""; return; fi local gateway="${bridge_cidr%%/*}" echo "${gateway%.*}.145" } SERVER_DIR="/opt/musicfs" LOG_DIR="/var/log/musicfs" BIN_NAME="musicfs-server" UNIT_NAME="musicfs-server.service" # ─────────────────────────────── helpers ─────────────────────────────── log() { printf '\033[1;34m[vm]\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33m[vm warn]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[vm error]\033[0m %s\n' "$*" >&2; exit 1; } require() { command -v "$1" >/dev/null 2>&1 || die "missing required command: $1 (run inside 'devenv shell')"; } vm_exists() { incus list "$VM_NAME" -f csv -c n 2>/dev/null | grep -qx "$VM_NAME"; } vm_running() { incus list "$VM_NAME" -f csv -c n,s 2>/dev/null | grep -q "$VM_NAME,.*RUNNING"; } device_exists() { incus config device show "$VM_NAME" 2>/dev/null | grep -q "^$1:"; } # incus -c 4 emits " ()"; keep only the address. vm_ip() { local ip ip="$(incus list "$VM_NAME" -f csv -c 4 2>/dev/null | head -1 | awk '{print $1}')" if [[ -n "$ip" ]]; then echo "$ip"; return; fi detect_static_ip } # ──────────────────────────── build + bundle ─────────────────────────── # Emits the host path to the prepared bundle dir on stdout. build_bundle() { require cargo require readelf log "building $BIN_NAME (release)..." cargo build --release --bin "$BIN_NAME" local bin="target/release/$BIN_NAME" local interp # readelf prints " [Requesting program interpreter: /path]" — capture the path. interp="$(readelf -l "$bin" | sed -n 's/.*interpreter: \(.*\)\]$/\1/p')" [ -n "$interp" ] || die "could not read ELF interpreter from $bin" log "binary interpreter: $interp" local bundle="target/vm-bundle" rm -rf "$bundle"; mkdir -p "$bundle/lib" cp "$bin" "$bundle/server" case "$interp" in /nix/store/*) log "nix-built binary detected; bundling glibc + libgcc_s for the VM..." require patchelf local libc libm libgcc libc="$(ldd "$bin" | sed -n 's#.*libc.so.6 => \([^ ]*\).*#\1#p')" libm="$(ldd "$bin" | sed -n 's#.*libm.so.6 => \([^ ]*\).*#\1#p')" libgcc="$(ldd "$bin"| sed -n 's#.*libgcc_s.so.1 => \([^ ]*\).*#\1#p')" [ -n "$libc" ] || die "could not resolve libc.so.6" [ -n "$libm" ] || die "could not resolve libm.so.6" [ -n "$libgcc" ] || die "could not resolve libgcc_s.so.1" cp "$interp" "$bundle/lib/ld-linux-x86-64.so.2" cp "$libc" "$bundle/lib/libc.so.6" cp "$libm" "$bundle/lib/libm.so.6" cp "$libgcc" "$bundle/lib/libgcc_s.so.1" patchelf --set-interpreter "$SERVER_DIR/lib/ld-linux-x86-64.so.2" "$bundle/server" patchelf --set-rpath "$SERVER_DIR/lib" "$bundle/server" ;; /*) warn "standard interpreter detected; shipping binary as-is." warn " (this only works if the VM's glibc >= the build host's glibc)." ;; *) die "unexpected interpreter '$interp'; refusing to ship." ;; esac chmod +x "$bundle/server" echo "$bundle" } # ───────────────────────────── VM lifecycle ──────────────────────────── create_vm() { require incus if vm_exists; then log "VM '$VM_NAME' already exists."; return 0; fi [ -d "$MUSIC_SOURCE" ] || die "MUSIC_SOURCE '$MUSIC_SOURCE' is not a directory" log "creating VM '$VM_NAME' from $IMAGE ..." incus init "$IMAGE" "$VM_NAME" --vm local static_ip static_ip="$(detect_static_ip)" if [[ -n "$static_ip" ]]; then log "reserving static IP: $static_ip" incus config device add "$VM_NAME" eth0 nic \ network=incusbr0 name=eth0 ipv4.address="$static_ip" fi if ! device_exists music; then local ro_flag="readonly=true" local mode="read-only" if [[ "$READONLY" == "0" ]]; then ro_flag="readonly=false" mode="read-write" fi log "sharing music: $MUSIC_SOURCE -> $MUSIC_MOUNT ($mode, virtiofs)" incus config device add "$VM_NAME" music disk \ source="$MUSIC_SOURCE" path="$MUSIC_MOUNT" "$ro_flag" fi # No proxy: Incus VM proxies need NAT mode + a *static* instance IP (DHCP is # rejected). Server binds 0.0.0.0 and the VM is on incusbr0 -> reach it at # its bridge IP (shown by `status`). } start_vm() { if vm_running; then return 0; fi log "starting VM '$VM_NAME'..." incus start "$VM_NAME" } wait_for_vm() { log "waiting for VM agent to accept commands..." local i for ((i = 0; i < 90; i++)); do if incus exec "$VM_NAME" -- true 2>/dev/null; then return 0; fi sleep 2 done die "VM '$VM_NAME' did not become reachable (incus-agent not up in time)" } # Push the bundle + install a systemd unit, then (re)start the service. deploy() { local bundle; bundle="$(build_bundle)" log "deploying bundle to $VM_NAME:$SERVER_DIR ..." incus exec "$VM_NAME" -- systemctl stop "$UNIT_NAME" 2>/dev/null || true incus exec "$VM_NAME" -- mkdir -p "$SERVER_DIR/lib" "$LOG_DIR" incus file push "$bundle/server" "$VM_NAME$SERVER_DIR/server" --mode=0755 # Ship the bundled libs one by one (robust across incus file-push versions). if [ -n "$(ls -A "$bundle/lib" 2>/dev/null)" ]; then local f for f in "$bundle/lib"/*; do incus file push "$f" "$VM_NAME$SERVER_DIR/lib/" done fi log "installing systemd unit ($UNIT_NAME)..." install_unit log "enabling + (re)starting $UNIT_NAME ..." incus exec "$VM_NAME" -- systemctl daemon-reload incus exec "$VM_NAME" -- systemctl enable "$UNIT_NAME" >/dev/null 2>&1 || true incus exec "$VM_NAME" -- systemctl restart "$UNIT_NAME" sleep 1 if incus exec "$VM_NAME" -- systemctl is-active --quiet "$UNIT_NAME"; then log "musicfs-server is running." else warn "service did not reach active state quickly; check: scripts/vm.sh logs" fi } install_unit() { local unit unit=$(cat </dev/null } # ────────────────────────────── subcommands ──────────────────────────── cmd_up() { require incus create_vm start_vm wait_for_vm deploy print_status } cmd_redeploy() { require incus vm_exists || die "VM '$VM_NAME' does not exist. Run 'up' first." vm_running || die "VM '$VM_NAME' is not running. Run 'up' first." wait_for_vm deploy print_status } cmd_restart() { require incus vm_running || die "VM '$VM_NAME' is not running." incus exec "$VM_NAME" -- systemctl restart "$UNIT_NAME" log "restarted." } cmd_logs() { require incus; vm_running || die "VM not running."; incus exec "$VM_NAME" -- journalctl -u "$UNIT_NAME" -f; } cmd_shell() { require incus; vm_running || die "VM not running."; exec incus exec "$VM_NAME" -- bash; } cmd_down() { require incus; vm_running || { log "already stopped."; return; }; incus stop "$VM_NAME"; log "stopped."; } cmd_destroy() { require incus vm_exists || { log "nothing to destroy."; return; } incus delete -f "$VM_NAME" log "VM '$VM_NAME' deleted." } print_status() { local ip; ip="$(vm_ip)" local state="missing"; vm_exists && state="$(incus list "$VM_NAME" -f csv -c s 2>/dev/null | head -1)" vm_running && state="RUNNING" echo printf '\033[1;36m== musicfs-server VM ==\033[0m\n' printf ' name: %s\n' "$VM_NAME" printf ' state: %s\n' "$state" printf ' ipv4: %s\n' "${ip:-}" printf ' image: %s\n' "$IMAGE" printf ' listen: 0.0.0.0:%s (reach via VM bridge IP above)\n' "$LISTEN_PORT" local ro_actual="read-only" if vm_exists; then local ro_val ro_val="$(incus config device get "$VM_NAME" music readonly 2>/dev/null || true)" [[ "$ro_val" == "false" ]] && ro_actual="read-write" else [[ "$READONLY" == "0" ]] && ro_actual="read-write" fi printf ' music: %s -> %s (%s)\n' "$MUSIC_SOURCE" "$MUSIC_MOUNT" "$ro_actual" printf ' server: %s/server\n' "$SERVER_DIR" if [ -n "$ip" ]; then printf '\n test: grpcurl -plaintext %s:%s list\n\n' "$ip" "$LISTEN_PORT" else printf '\n test: grpcurl -plaintext :%s list\n\n' "$LISTEN_PORT" fi } cmd_status() { require incus; print_status; incus exec "$VM_NAME" -- systemctl status "$UNIT_NAME" --no-pager 2>/dev/null || true; } # ───────────────────────────────── main ──────────────────────────────── usage() { sed -n '3,/^$/p' "$0" | sed 's/^# \?//' exit 1 } main() { local cmd="${1:-up}" shift || true while (( $# )); do case "$1" in --writable) [[ -z "${READONLY:-}" ]] && READONLY=0 ;; --readonly) [[ -z "${READONLY:-}" ]] && READONLY=1 ;; *) die "unknown flag '$1'. Try: $0 --help" ;; esac shift done READONLY="${READONLY:-1}" case "$cmd" in up) cmd_up ;; redeploy) cmd_redeploy ;; restart) cmd_restart ;; logs) cmd_logs ;; status) cmd_status ;; shell) cmd_shell ;; down) cmd_down ;; destroy) cmd_destroy ;; -h|--help|help) usage ;; *) die "unknown command '$cmd'. Try: $0 --help" ;; esac } main "$@"