diff --git a/README.org b/README.org new file mode 100644 index 0000000..b926032 --- /dev/null +++ b/README.org @@ -0,0 +1,54 @@ +#+title: musicfs + +* Incus server VM + +~musicfs-server~ (gRPC) runs inside an Incus VM, live-sharing the host's +~~/Music~ as a readonly virtiofs mount. =scripts/vm.sh= builds the binary on the +host, bundles its nix ~glibc~ so it runs unmodified in the VM, ships it in, and +runs it under systemd. + +** Setup + +#+begin_src bash +devenv shell # cargo, incus, patchelf, grpcurl +scripts/vm.sh up # create VM, share ~/Music, build, ship, start server +#+end_src + +Options (env): =VM_NAME=, =IMAGE=, =MUSIC_SOURCE=, =LISTEN_PORT=, =RUST_LOG=. + +| Command | Action | +|--------------------------+-------------------------------| +| =scripts/vm.sh up= | create + build + ship + start | +| =scripts/vm.sh redeploy= | rebuild after a code change | +| =scripts/vm.sh logs= | tail server logs | +| =scripts/vm.sh status= | VM + service status | +| =scripts/vm.sh shell= | shell inside the VM | +| =scripts/vm.sh down= | stop the VM | +| =scripts/vm.sh destroy= | delete the VM | + +** Test it (Nushell) + +#+begin_src nushell +let VMIP = (incus list musicfs -f csv -c 4 | lines | first | split row " " | first) +let ADDR = $"($VMIP):50051" + +# liveness +^nc -z -w 3 $VMIP 50051 +if $env.LAST_EXIT_CODE == 0 { print "alive" } else { print "dead" } + +# gRPC: stream the manifest, count entries +grpcurl -plaintext -import-path proto -proto musicfs.proto -d "{}" $ADDR musicfs.MusicFs/GetManifest | lines | find relPath | length + +# files the server scans +incus exec musicfs -- find /music -type f | lines | length + +# play a track straight out of the VM's shared /music +incus exec musicfs -- cat "/music/DDT/ДДТ - Творчество в пустоте – 2 - 01 Intro.flac" | ^mpv - +#+end_src + +Reach the server at the VM's bridge IP (=scripts/vm.sh status= prints it). +Install mpv with =nix profile install nixpkgs#mpv=, or use =^ffplay -= instead. + +In Nushell, always put =| lines= between an external command's stdout and a +builtin (=find=, =first=, =length=, ...); external-to-external pipes (=cat | +mpv=) are byte-stable and don't need it. diff --git a/scripts/vm.sh b/scripts/vm.sh new file mode 100755 index 0000000..749cce5 --- /dev/null +++ b/scripts/vm.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env 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) +# +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}" + +# Fixed inside the VM; the bundle's interpreter+rpath point here. +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() { incus list "$VM_NAME" -f csv -c 4 2>/dev/null | head -1 | awk '{print $1}'; } + +# ──────────────────────────── 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 + + if ! device_exists music; then + log "sharing music: $MUSIC_SOURCE -> $MUSIC_MOUNT (readonly, virtiofs)" + incus config device add "$VM_NAME" music disk \ + source="$MUSIC_SOURCE" path="$MUSIC_MOUNT" readonly=true + 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 ..." + # Stop first: overwriting a running binary fails with ETXTBSY ("text file busy"). + if incus exec "$VM_NAME" -- systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then + log "stopping $UNIT_NAME (binary in use)..." + incus exec "$VM_NAME" -- systemctl stop "$UNIT_NAME" + fi + 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" + printf ' music: %s -> %s\n' "$MUSIC_SOURCE" "$MUSIC_MOUNT" + 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() { + case "${1:-up}" 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 '$1'. Try: $0 --help" ;; + esac +} + +main "$@"