diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index ddb1e6aa..1a0a3d6b 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -325,26 +325,34 @@ async fn systemd_daemon_reload() -> Result<()> { Ok(()) } -/// Idempotently rewrite the `EXTRA_NSPAWN_FLAGS` line in -/// `/etc/nixos-containers/.conf`. The start script expands this -/// variable unquoted into the `systemd-nspawn` command. +/// Idempotently rewrite the lines in `/etc/nixos-containers/.conf` +/// that hive-c0re owns: `PRIVATE_NETWORK` (forced 0 so the agent's web UI port +/// is reachable on the host) and `EXTRA_NSPAWN_FLAGS` (the runtime-dir bind). +/// The start script expands `$EXTRA_NSPAWN_FLAGS` unquoted into the +/// `systemd-nspawn` command. fn set_nspawn_flags(container: &str, agent_dir: &Path) -> Result<()> { let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; - let flag = format!( + let bind_flag = format!( "EXTRA_NSPAWN_FLAGS=\"--bind={}:{CONTAINER_RUNTIME_MOUNT}\"", agent_dir.display() ); let mut lines: Vec = original .lines() - .filter(|line| !line.trim_start().starts_with("EXTRA_NSPAWN_FLAGS=")) + .filter(|line| { + let trimmed = line.trim_start(); + !trimmed.starts_with("EXTRA_NSPAWN_FLAGS=") + && !trimmed.starts_with("PRIVATE_NETWORK=") + }) .map(str::to_owned) .collect(); - lines.push(flag); + // Share host netns so per-agent web UI ports are reachable directly. + lines.push("PRIVATE_NETWORK=0".to_owned()); + lines.push(bind_flag); let mut content = lines.join("\n"); content.push('\n'); std::fs::write(&path, content).with_context(|| format!("write {path}"))?; - tracing::info!(%path, "set EXTRA_NSPAWN_FLAGS"); + tracing::info!(%path, "set PRIVATE_NETWORK=0 + EXTRA_NSPAWN_FLAGS"); Ok(()) } diff --git a/tests/dashboard.sh b/tests/dashboard.sh new file mode 100755 index 00000000..c2683750 --- /dev/null +++ b/tests/dashboard.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Phase 6 + 7 smoke test: dashboard HTTP, per-container web UIs, +# approve-by-POST, SSE endpoint, and orphan-approval GC. +# +# Runs as root on a host with services.hive-c0re enabled and the hm1nd +# container declared. Idempotent — wipes any prior alice state. + +set -euo pipefail + +AGENT=alice +PKG=htop +HOST=${HOST:-localhost} +DASH_PORT=${DASH_PORT:-7000} +MGR_PORT=${MGR_PORT:-8000} + +cleanup() { + sudo hive-c0re kill "$AGENT" 2>/dev/null || true + sudo nixos-container destroy "h-${AGENT}" 2>/dev/null || true + sudo rm -rf \ + "/var/lib/hyperhive/agents/${AGENT}" \ + "/var/lib/hyperhive/applied/${AGENT}" +} + +echo "=== precheck: hm1nd container is up ===" +if ! sudo machinectl status hm1nd >/dev/null 2>&1; then + echo " starting via systemd..." + sudo systemctl start container@hm1nd.service + for i in 1 2 3 4 5 6 7 8 9 10; do + sudo machinectl status hm1nd >/dev/null 2>&1 && break + sleep 0.5 + done +fi +echo " ✓ hm1nd is up" + +cleanup + +echo "=== dashboard reachable ===" +curl -sf "http://${HOST}:${DASH_PORT}/" | grep -q "HYPERHIVE" || { + echo "FAIL: dashboard not serving expected content" + exit 1 +} +echo " ✓ http://${HOST}:${DASH_PORT}/ → HTML with HYPERHIVE banner" + +echo "=== /messages/stream SSE endpoint ===" +curl -sI "http://${HOST}:${DASH_PORT}/messages/stream" \ + | grep -qi "content-type: text/event-stream" || { + echo "FAIL: /messages/stream is not SSE" + exit 1 +} +echo " ✓ Content-Type: text/event-stream" + +echo "=== manager UI reachable ===" +curl -sf "http://${HOST}:${MGR_PORT}/" | grep -q "hm1nd" || { + echo "FAIL: manager UI not reachable at :${MGR_PORT}" + exit 1 +} +echo " ✓ http://${HOST}:${MGR_PORT}/ → hm1nd label" + +echo "=== spawn ${AGENT} ===" +sudo hive-c0re spawn "$AGENT" + +echo "=== pick up ${AGENT}'s port from dashboard ===" +sleep 2 +ALICE_PORT=$(curl -sf "http://${HOST}:${DASH_PORT}/" \ + | sed -nE "s|.*href=\"http://[^:]+:([0-9]+)/\">${AGENT}<.*|\\1|p" \ + | head -1) +if [ -z "$ALICE_PORT" ]; then + echo "FAIL: ${AGENT} port not found in dashboard" + exit 1 +fi +echo " ✓ ${AGENT} port: ${ALICE_PORT}" + +echo "=== ${AGENT} UI reachable ===" +for i in 1 2 3 4 5 6 7 8 9 10; do + curl -sf "http://${HOST}:${ALICE_PORT}/" | grep -q "${AGENT}" && break + sleep 1 +done +curl -sf "http://${HOST}:${ALICE_PORT}/" | grep -q "${AGENT}" || { + echo "FAIL: ${AGENT} UI not reachable at :${ALICE_PORT}" + exit 1 +} +echo " ✓ http://${HOST}:${ALICE_PORT}/ → ${AGENT} label" + +echo "=== ${PKG} not in path pre-approve ===" +if sudo nixos-container run "h-${AGENT}" -- which "$PKG" 2>/dev/null; then + echo "FAIL: ${PKG} already in path" + exit 1 +fi +echo " ✓" + +echo "=== manager submits approval (via hm1nd) ===" +sudo nixos-container run hm1nd -- bash -c " + set -euo pipefail + cd /agents/${AGENT}/config + cat > agent.nix <<'EOF' +{ pkgs, ... }: +{ + environment.systemPackages = [ pkgs.${PKG} ]; +} +EOF + git commit -am 'add ${PKG}' + SHA=\$(git rev-parse HEAD) + echo \" manager commit SHA=\$SHA\" + hive-m1nd request-apply-commit ${AGENT} \$SHA +" + +echo "=== pick approval id from dashboard ===" +ID=$(curl -sf "http://${HOST}:${DASH_PORT}/" \ + | sed -nE 's|.*hive-c0re approve ([0-9]+).*|\1|p' \ + | head -1) +if [ -z "$ID" ]; then + echo "FAIL: approval id not found in dashboard" + exit 1 +fi +echo " ✓ approval id: ${ID}" + +echo "=== POST /approve/${ID} (browser button equivalent) ===" +HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \ + -X POST "http://${HOST}:${DASH_PORT}/approve/${ID}") +if [ "$HTTP_CODE" != "303" ] && [ "$HTTP_CODE" != "302" ]; then + echo "FAIL: expected 303 redirect, got HTTP ${HTTP_CODE}" + exit 1 +fi +echo " ✓ HTTP ${HTTP_CODE} (redirect)" + +echo "=== verify ${PKG} now in h-${AGENT} ===" +sudo nixos-container run "h-${AGENT}" -- which "$PKG" + +echo "=== orphan-GC: cleanup ${AGENT}, refresh dashboard, orphan disappears ===" +sudo nixos-container run hm1nd -- bash -c " + set -euo pipefail + cd /agents/${AGENT}/config + echo '{ ... }: { }' > agent.nix + git commit -am 'noop' + SHA=\$(git rev-parse HEAD) + hive-m1nd request-apply-commit ${AGENT} \$SHA +" +ORPHAN_ID=$(sudo hive-c0re pending \ + | sed -nE 's/^[[:space:]]*"id":[[:space:]]*([0-9]+).*/\1/p' \ + | tail -1) +echo " queued orphan id: ${ORPHAN_ID}" +cleanup +# First dashboard render triggers GC. +curl -sf "http://${HOST}:${DASH_PORT}/" >/dev/null +sleep 0.5 +if sudo hive-c0re pending | grep -q "\"id\": ${ORPHAN_ID}"; then + echo "FAIL: orphan ${ORPHAN_ID} still pending after dashboard render" + exit 1 +fi +echo " ✓ orphan ${ORPHAN_ID} cleaned up" + +echo +echo "=== summary ===" +echo " dashboard http://${HOST}:${DASH_PORT}/" +echo " manager UI http://${HOST}:${MGR_PORT}/" +echo " (alice torn down)"