From b88caa291d685bcd49ec9fdb9b886a40e086c02f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 10 Jul 2026 15:37:32 -0500 Subject: [PATCH] feat: test VM build/run script with SSH diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nixos/vm.nix: enable PasswordAuthentication for kiosk user (SSH test access) - scripts/test-vm.sh: one-command build → start → diagnose workflow - macOS: builds and runs on remote Linux server, SSH through server - Linux: builds and runs locally - Commands: --build, --start, --stop, --diagnose, --shell - Diagnostics: NetworkManager, PipeWire, wpctl, ALSA, pactl, processes, logs - AGENTS.md: compact agent guide for the repo --- AGENTS.md | 58 ++++++ nixos/vm.nix | 10 +- scripts/test-vm.sh | 445 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100755 scripts/test-vm.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..541908a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# RadiantWave Agent Guide + +## What This Is + +Go kiosk application using SDL2, OpenGL 3.3, SQLite (via GORM), and D-Bus (NetworkManager). Nix flake is the primary build system. CI runs on Forgejo (not GitHub Actions). + +## Build & Run + +Dev shell via direnv (`use flake` in `.envrc`). Inside the nix dev shell: + +```sh +# Build +go build -o bin/rhemawave rhemawave.go + +# Run +./bin/rhemawave + +# Nix build (CI uses this) +nix build -L +``` + +CGO is required (`CGO_ENABLED=1`). The nix dev shell and `flake.nix` `preBuild` set the necessary `CGO_CFLAGS` and `CGO_LDFLAGS` for SDL2. Do not attempt a plain `go build` outside the nix shell — it will fail on missing SDL2 headers. + +## Tests + +Only one test suite exists: `internal/mixer/mixer_test.go`. Run with: + +```sh +go test ./internal/mixer/ +``` + +No lint or typecheck commands are configured in the repo. CI only runs `nix build -L`. + +## Branch Model + +`dev` → `main` via pull requests. CI workflows are in `.forgejo/workflows/`: +- `build-dev.yml`: PRs to `dev` +- `build-prod.yml`: PRs to `main` + +Both run `nix build -L` on a self-hosted runner (`neils-server-runner`). + +## Key Architecture + +- **Entry**: `rhemawave.go` → `internal/application.Run()` +- **State machine**: `internal/application/application.go` — FIRST_RUN → NEEDS_INTERNET → NEEDS_EMAIL_ADDRESS → NEEDS_LICENSE_KEY → DOWNLOADING_ASSETS → READY +- **Pages**: `internal/page/` — router pattern with page interface (`page.go`) +- **DB**: SQLite at `~/.local/share/rhemawave/database.sqlite`, managed by GORM in `internal/db/` +- **Assets**: `share/rhemawave/` (fonts, shaders, proclamations, tos) — copied into nix store at build time +- **Audio**: `.wav` files are gitignored, downloaded at runtime from `files.radiantwavetech.com` +- **Vendor**: Dependencies are vendored and committed. Use `go mod vendor` after adding deps. + +## Common Pitfalls + +- The `dev_scripts/reset.sh` referenced in the nix devShell hook does not exist in the repo. +- `share/rhemawave/audio/*.wav` is gitignored — do not commit audio assets. +- The `vendor/` directory is committed — always run `go mod vendor` after `go mod tidy`. +- CI runs on a self-hosted Forgejo runner, not GitHub-hosted runners. Workflows source nix via `/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh`. +- The `packaging/` directory contains a kiosk-specific updater script and is not part of the main build. diff --git a/nixos/vm.nix b/nixos/vm.nix index 09a5c71..db2334b 100644 --- a/nixos/vm.nix +++ b/nixos/vm.nix @@ -62,14 +62,20 @@ in systemd.network.wait-online.enable = false; # ── SSH ─────────────────────────────────────────────────────────────────── - services.openssh.enable = true; - services.openssh.settings.PermitRootLogin = "prohibit-password"; + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "prohibit-password"; + PasswordAuthentication = true; + }; + }; # ── User (matches production) ───────────────────────────────────────────── users.users.kiosk = { isNormalUser = true; uid = 1000; extraGroups = [ "audio" "video" "input" "networkmanager" "wheel" ]; + password = "kiosk"; }; services.getty.autologinUser = "kiosk"; diff --git a/scripts/test-vm.sh b/scripts/test-vm.sh new file mode 100755 index 0000000..93d8e0e --- /dev/null +++ b/scripts/test-vm.sh @@ -0,0 +1,445 @@ +#!/usr/bin/env bash +# test-vm.sh — Build, start, and diagnose the RadiantWave NixOS test VM. +# +# On macOS (ARM): builds and runs the VM on a remote Linux server. +# On Linux (x86_64): builds and runs locally. +# +# Usage: +# ./scripts/test-vm.sh Build + start + run diagnostics +# ./scripts/test-vm.sh --build Build only +# ./scripts/test-vm.sh --start Start only (must already be built) +# ./scripts/test-vm.sh --stop Stop the running VM +# ./scripts/test-vm.sh --diagnose Run diagnostics on a running VM +# ./scripts/test-vm.sh --shell Interactive SSH shell into the VM +# +# Environment variables: +# RADIANTWAVE_REMOTE Remote server for macOS builds (default: nclack@192.168.1.203) +# RADIANTWAVE_REMOTE_DIR Remote repo path (default: ~/radiantwave) + +set -euo pipefail + +# ── Paths & defaults ────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REMOTE="${RADIANTWAVE_REMOTE:-nclack@192.168.1.203}" +REMOTE_DIR="${RADIANTWAVE_REMOTE_DIR:-~/radiantwave}" +SSH_PORT=2222 +VM_USER="kiosk" +VM_PASS="kiosk" + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m' +DIM='\033[2m'; BOLD='\033[1m'; NC='\033[0m' + +log() { echo -e "${BLUE}▸${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +err() { echo -e "${RED}✗${NC} $*" >&2; } +hr() { echo -e "${DIM}────────────────────────────────────────────────${NC}"; } + +# ── Platform detection ──────────────────────────────────────────────────────── +detect_platform() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Darwin) echo "macos" ;; + Linux) + case "$arch" in + x86_64) echo "linux" ;; + aarch64) echo "linux-arm" ;; + *) echo "linux-unknown" ;; + esac + ;; + *) echo "unknown" ;; + esac +} + +PLATFORM="$(detect_platform)" + +# ── Dependency checks ───────────────────────────────────────────────────────── +check_deps() { + local missing=() + + if ! command -v nix &>/dev/null; then + missing+=("nix") + fi + + if [[ "$PLATFORM" == "macos" ]]; then + for cmd in ssh rsync; do + command -v "$cmd" &>/dev/null || missing+=("$cmd") + done + fi + + if (( ${#missing[@]} > 0 )); then + err "Missing required commands: ${missing[*]}" + exit 1 + fi + + # sshpass — try direct, then nix-shell wrapper + if command -v sshpass &>/dev/null; then + SSPASS="sshpass" + elif nix-shell -p sshpass --run 'which sshpass' &>/dev/null; then + SSPASS="nix-shell -p sshpass --run" + else + err "sshpass not found. Install via: brew install sshpass (macOS) or nix profile install nixpkgs#sshpass" + exit 1 + fi +} + +# ── VM path helpers ─────────────────────────────────────────────────────────── +# The nix build output is a directory containing bin/run-radiantwave-test-vm. +VM_RESULT_FILE="/tmp/radiantwave-test-vm-path" +VM_BIN_DIR="" + +resolve_vm_bin() { + if [[ -n "$VM_BIN_DIR" ]]; then + return + fi + + local cached="" + if [[ "$PLATFORM" == "linux" ]]; then + if [[ -f "$VM_RESULT_FILE" ]]; then + cached="$(cat "$VM_RESULT_FILE")" + if [[ -x "$cached/bin/run-radiantwave-test-vm" ]]; then + VM_BIN_DIR="$cached" + return + fi + fi + else + cached="$(ssh "$REMOTE" "cat '$VM_RESULT_FILE' 2>/dev/null")" || true + if [[ -n "$cached" ]]; then + # Check if it exists on the remote server + if ssh "$REMOTE" "test -x '$cached/bin/run-radiantwave-test-vm'" 2>/dev/null; then + VM_BIN_DIR="$cached" + return + fi + fi + fi + + return 1 +} + +# ── Platform-specific execution ─────────────────────────────────────────────── +# On Linux: run commands locally. +# On macOS: sync repo to remote, then run commands there via SSH. +run_cmd() { + if [[ "$PLATFORM" == "linux" ]]; then + "$@" + else + # Run on remote server + ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")" + fi +} + +run_cmd_capture() { + if [[ "$PLATFORM" == "linux" ]]; then + "$@" + else + ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")" + fi +} + +sync_to_remote() { + if [[ "$PLATFORM" == "macos" ]]; then + log "Syncing repo to remote server ($REMOTE)..." + # Ensure remote dir exists + ssh "$REMOTE" "mkdir -p '$REMOTE_DIR'" + rsync -avz --delete \ + --exclude='.git/' \ + --exclude='result' \ + --exclude='result-*' \ + --exclude='bin/' \ + --exclude='build/' \ + "$REPO_ROOT/" "$REMOTE:$REMOTE_DIR/" + fi +} + +# ── Commands ────────────────────────────────────────────────────────────────── + +cmd_build() { + log "Building test VM ($PLATFORM)..." + sync_to_remote + + local out_path + if [[ "$PLATFORM" == "linux" ]]; then + out_path="$(nix build ".#packages.x86_64-linux.test-vm" --print-out-paths 2>&1)" + echo "$out_path" > "$VM_RESULT_FILE" + VM_BIN_DIR="$out_path" + else + # Build on remote, save path there + out_path="$(ssh "$REMOTE" "cd '$REMOTE_DIR' && nix build .#packages.x86_64-linux.test-vm --print-out-paths 2>&1")" + ssh "$REMOTE" "echo '$out_path' > $VM_RESULT_FILE" + VM_BIN_DIR="$out_path" + fi + + ok "VM built: $VM_BIN_DIR" +} + +cmd_start() { + if ! resolve_vm_bin 2>/dev/null; then + err "VM not built yet. Run: $0 --build" + exit 1 + fi + + if vm_is_running; then + warn "VM already running (PID $(cat /tmp/radiantwave-test-vm.pid 2>/dev/null || echo '?'))" + return + fi + + local vm_script="$VM_BIN_DIR/bin/run-radiantwave-test-vm" + log "Starting VM in background..." + + if [[ "$PLATFORM" == "linux" ]]; then + # Run locally with serial to a log file + nohup "$vm_script" \ + -nographic \ + -serial file:/tmp/radiantwave-test-vm-serial.log \ + > /tmp/radiantwave-test-vm.log 2>&1 & + echo $! > /tmp/radiantwave-test-vm.pid + ok "VM started (PID $!)" + else + # Run on remote server — use nohup so the VM survives SSH disconnect + ssh "$REMOTE" "cd '$REMOTE_DIR' && \ + nohup $vm_script -nographic \ + -serial file:/tmp/radiantwave-test-vm-serial.log \ + > /tmp/radiantwave-test-vm.log 2>&1 & \ + echo \$! > /tmp/radiantwave-test-vm.pid && \ + echo \"VM started on remote (PID \$(cat /tmp/radiantwave-test-vm.pid))\"" + ok "VM started on remote server" + fi +} + +cmd_stop() { + if [[ "$PLATFORM" == "linux" ]]; then + if [[ -f /tmp/radiantwave-test-vm.pid ]]; then + local pid + pid="$(cat /tmp/radiantwave-test-vm.pid)" + if kill -0 "$pid" 2>/dev/null; then + log "Stopping VM (PID $pid)..." + kill "$pid" + rm -f /tmp/radiantwave-test-vm.pid + ok "VM stopped" + else + warn "VM process $pid not running" + rm -f /tmp/radiantwave-test-vm.pid + fi + else + warn "No PID file found. VM may not be running." + fi + else + log "Stopping VM on remote server..." + ssh "$REMOTE" " + if [[ -f /tmp/radiantwave-test-vm.pid ]]; then + pid=\$(cat /tmp/radiantwave-test-vm.pid) + kill \$pid 2>/dev/null && echo 'VM stopped' || echo 'VM not running' + rm -f /tmp/radiantwave-test-vm.pid + else + echo 'No PID file found' + fi + " + fi +} + +vm_is_running() { + if [[ "$PLATFORM" == "linux" ]]; then + [[ -f /tmp/radiantwave-test-vm.pid ]] && \ + kill -0 "$(cat /tmp/radiantwave-test-vm.pid)" 2>/dev/null + else + ssh "$REMOTE" " + [[ -f /tmp/radiantwave-test-vm.pid ]] && \ + kill -0 \$(cat /tmp/radiantwave-test-vm.pid) 2>/dev/null + " 2>/dev/null + fi +} + +vm_ssh() { + # Run a command in the VM via SSH. Usage: vm_ssh "command" + local cmd="$1" + local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + if [[ "$PLATFORM" == "linux" ]]; then + eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost' '$cmd'" + else + # On macOS: find sshpass on the remote server, then SSH to the VM + ssh "$REMOTE" " + SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null) + if [[ -z \"\$SSPASS\" ]]; then + echo 'sshpass not found on remote server' >&2 + exit 1 + fi + \$SSPASS -p '$VM_PASS' ssh $ssh_opts -p $SSH_PORT ${VM_USER}@localhost '$cmd' + " + fi +} + +vm_wait_for_boot() { + local max_wait=90 + local waited=0 + + log "Waiting for VM to boot (up to ${max_wait}s)..." + + while (( waited < max_wait )); do + if vm_ssh "echo ok" &>/dev/null; then + ok "VM ready after ${waited}s" + return 0 + fi + sleep 2 + waited=$((waited + 2)) + echo -ne "\r ${DIM}${waited}s / ${max_wait}s${NC}" + done + + echo + err "VM did not become ready within ${max_wait}s" + return 1 +} + +cmd_diagnose() { + hr + echo -e "${BOLD} RadiantWave Test VM — Diagnostic Report${NC}" + hr + + log "Connecting to VM..." + + # System services + echo -e "\n${BOLD}── System Services ──${NC}" + vm_ssh "systemctl list-units --type=service --state=running --no-pager | grep -E 'NetworkManager|sshd|seatd|getty|cage|systemd-logind' || true" 2>/dev/null || warn "Could not query system services" + + # User services (PipeWire stack) + echo -e "\n${BOLD}── User Services (PipeWire) ──${NC}" + vm_ssh "systemctl --user list-units --type=service --state=running --no-pager 2>/dev/null | grep -E 'pipewire|wireplumber|pulse' || echo 'No user services running'" 2>/dev/null || warn "Could not query user services" + + # Audio devices via wpctl + echo -e "\n${BOLD}── PipeWire Devices (wpctl status) ──${NC}" + vm_ssh "wpctl status 2>/dev/null || echo 'wpctl not available'" 2>/dev/null || warn "Could not query wpctl" + + # ALSA devices + echo -e "\n${BOLD}── ALSA Devices (aplay -l) ──${NC}" + vm_ssh "aplay -l 2>/dev/null || echo 'No ALSA devices'" 2>/dev/null || warn "Could not query ALSA" + + # PulseAudio sinks + echo -e "\n${BOLD}── PulseAudio Sinks (pactl) ──${NC}" + vm_ssh "pactl list sinks short 2>/dev/null || echo 'pactl not available'" 2>/dev/null || warn "Could not query PulseAudio" + + # Network devices + echo -e "\n${BOLD}── Network Devices ──${NC}" + vm_ssh "nmcli device status 2>/dev/null || echo 'nmcli not available'" 2>/dev/null || warn "Could not query network" + + # Running processes + echo -e "\n${BOLD}── Key Processes ──${NC}" + vm_ssh "ps aux | grep -E 'rhemawave|cage|pipewire|wireplumber|NetworkManager' | grep -v grep || echo 'No matching processes'" 2>/dev/null || warn "Could not query processes" + + # Kiosk session + echo -e "\n${BOLD}── Login Sessions ──${NC}" + vm_ssh "loginctl list-sessions --no-pager 2>/dev/null || echo 'loginctl not available'" 2>/dev/null || warn "Could not query sessions" + + # App log (last 30 lines) + echo -e "\n${BOLD}── App Log (/tmp/kiosk.log, last 30 lines) ──${NC}" + vm_ssh "tail -30 /tmp/kiosk.log 2>/dev/null || echo 'No log file yet'" 2>/dev/null || warn "Could not read app log" + + hr +} + +cmd_shell() { + log "Connecting interactive shell to VM..." + local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + if [[ "$PLATFORM" == "linux" ]]; then + eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost'" + else + log "Connecting via remote server ($REMOTE)..." + ssh -t "$REMOTE" " + SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null) + if [[ -z \"\$SSPASS\" ]]; then + echo 'sshpass not found on remote server' >&2 + exit 1 + fi + \$SSPASS -p '$VM_PASS' ssh $ssh_opts -t -p $SSH_PORT ${VM_USER}@localhost + " + fi +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +usage() { + cat <