From 9b9e82c54729556f4906d2be941ac93727d026c5 Mon Sep 17 00:00:00 2001 From: "Younes (Machine Wisdom)" Date: Sat, 11 Jul 2026 12:34:34 -0400 Subject: [PATCH] feat: add fail-closed tunnel preflight --- README.md | 11 ++++ examples/tunnel-gateway/README.md | 20 +++++++ examples/tunnel-gateway/compose.yaml | 14 +++++ src/fava_trails/tunnel_cli.py | 36 ++++++++++- tests/test_tunnel_cli.py | 90 ++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 examples/tunnel-gateway/README.md create mode 100644 examples/tunnel-gateway/compose.yaml diff --git a/README.md b/README.md index ca4780b..ce6faf3 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,17 @@ tunnel exposure. Keep `--sync-interval-seconds` at its default `0` when no later autosync is wanted; a positive interval additionally starts the recurring worker without repeating the initial sync. +Before starting the external tunnel, deployments can validate the same private +runtime and optionally the tunnel profile without exposing it: + +```bash +fava-trails-tunnel preflight --data-repo /path/to/fava-trails-data --profile fava-trails --tunnel-doctor +``` + +`preflight` starts only the loopback HTTP runtime, waits for `/healthz`, runs the +optional doctor, and always stops the private runtime before returning. It never +starts the external tunnel. + The tunnel startup wait and `status` command consume `/healthz`; `status` exits non-zero when the supervisor is running but its data is not ready: diff --git a/examples/tunnel-gateway/README.md b/examples/tunnel-gateway/README.md new file mode 100644 index 0000000..6b1dedd --- /dev/null +++ b/examples/tunnel-gateway/README.md @@ -0,0 +1,20 @@ +# FAVA Tunnel Gateway Example + +This is a generic Compose command example, not a canonical deployment. + +Run a non-exposing check before the service is started: + +```bash +docker compose run --rm fava-tunnel \ + fava-trails-tunnel preflight \ + --data-repo "$FAVA_DATA_REPO" \ + --profile "$FAVA_TUNNEL_PROFILE" \ + --tunnel-doctor +``` + +The service performs one startup sync and sets the interval to zero, so it does +not run recurring synchronization. After startup, its private readiness endpoint +is available at `http://127.0.0.1:8765/healthz` inside the service network. + +Production deployments must independently provision credentials, initialize the +data repository, and select persistent storage appropriate to their environment. diff --git a/examples/tunnel-gateway/compose.yaml b/examples/tunnel-gateway/compose.yaml new file mode 100644 index 0000000..a46f751 --- /dev/null +++ b/examples/tunnel-gateway/compose.yaml @@ -0,0 +1,14 @@ +services: + fava-tunnel: + image: ${FAVA_TRAILS_IMAGE:?set a reviewed FAVA Trails image reference} + command: + - fava-trails-tunnel + - run + - --data-repo + - ${FAVA_DATA_REPO:?set the in-container data repository path} + - --profile + - ${FAVA_TUNNEL_PROFILE:?set the tunnel profile name} + - --sync-on-start + - --sync-interval-seconds + - "0" + - --tunnel-doctor diff --git a/src/fava_trails/tunnel_cli.py b/src/fava_trails/tunnel_cli.py index 5140d49..50ff87b 100644 --- a/src/fava_trails/tunnel_cli.py +++ b/src/fava_trails/tunnel_cli.py @@ -648,9 +648,11 @@ def handle_shutdown(signum, frame): # noqa: ARG001 initial_sync_requested = bool(getattr(args, "sync_on_start", False)) or sync_interval > 0 if initial_sync_requested: sync_state = _sync_data_repo(config, health_path, timeout=sync_timeout) - print(f" Data repo sync: {sync_state['status']}") - if sync_state["status"] != "ok": - raise ValueError(f"initial data repo sync failed: {sync_state.get('message', '')}") + sync_status = sync_state.get("status") if isinstance(sync_state, dict) else None + print(f" Data repo sync: {sync_status or 'invalid'}") + if sync_status != "ok": + message = sync_state.get("message", "") if isinstance(sync_state, dict) else "missing structured result" + raise ValueError(f"initial data repo sync failed: {message}") if sync_interval > 0: sync_thread = _start_sync_worker( @@ -716,6 +718,27 @@ def handle_shutdown(signum, frame): # noqa: ARG001 signal.signal(signal.SIGTERM, original_sigterm) +def cmd_preflight(args: argparse.Namespace) -> int: + """Validate the private runtime and optional tunnel profile without exposure.""" + http_process: subprocess.Popen | None = None + try: + run_doctor = bool(getattr(args, "tunnel_doctor", False)) + config = _load_gateway_config(args, require_tunnel_client=run_doctor) + _check_port_available(config.host, config.port) + http_process = _start_http_runtime(config) + _wait_for_health(config.health_url, http_process, timeout=args.ready_timeout) + if run_doctor: + _run_tunnel_doctor(config) + print("FAVA Trails tunnel preflight: ok") + return 0 + except (OSError, ValueError, subprocess.SubprocessError, TimeoutError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + finally: + if http_process is not None: + _terminate_process(http_process) + + def cmd_start(args: argparse.Namespace) -> int: process: subprocess.Popen | None = None state_dir: Path | None = None @@ -932,6 +955,13 @@ def build_parser() -> argparse.ArgumentParser: p_start.add_argument("--sync-timeout-seconds", type=float, default=DEFAULT_SYNC_TIMEOUT_SECONDS, help=f"Seconds before a data repo sync is marked failed (default: {DEFAULT_SYNC_TIMEOUT_SECONDS:g})") p_start.set_defaults(func=cmd_start) + p_preflight = subparsers.add_parser("preflight", help="Validate the private runtime without starting the external tunnel") + _add_common_args(p_preflight) + p_preflight.add_argument("--tunnel-client", default="tunnel-client", help="Path to tunnel-client binary") + p_preflight.add_argument("--ready-timeout", type=float, default=20.0, help="Seconds to wait for local MCP readiness") + p_preflight.add_argument("--tunnel-doctor", action="store_true", help="Run tunnel-client doctor after private readiness succeeds") + p_preflight.set_defaults(func=cmd_preflight) + p_stop = subparsers.add_parser("stop", help="Stop a detached gateway") _add_common_args(p_stop) p_stop.add_argument("--timeout", type=float, default=5.0, help="Seconds to wait before forcing stop") diff --git a/tests/test_tunnel_cli.py b/tests/test_tunnel_cli.py index 176ce5a..9eb17ce 100644 --- a/tests/test_tunnel_cli.py +++ b/tests/test_tunnel_cli.py @@ -4,6 +4,7 @@ import argparse import json +import subprocess from pathlib import Path from unittest.mock import MagicMock, patch @@ -22,6 +23,7 @@ DEFAULT_PROFILE, _load_gateway_config, _runtime_env, + cmd_preflight, cmd_run, cmd_start, cmd_status, @@ -344,6 +346,23 @@ def test_run_does_not_expose_when_startup_sync_times_out(tmp_path, monkeypatch): start_tunnel.assert_not_called() +def test_run_does_not_expose_when_startup_sync_result_is_unstructured(tmp_path, monkeypatch): + data_repo = _make_data_repo(tmp_path) + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") + args = _args(data_repo=str(data_repo), sync_on_start=True) + + with patch("fava_trails.tunnel_cli._find_jj_bin", return_value="/usr/bin/jj"): + with patch("shutil.which", return_value="/usr/bin/tunnel-client"): + with patch("fava_trails.tunnel_cli._check_port_available"): + with patch("fava_trails.tunnel_cli._sync_data_repo", return_value=None): + with patch("fava_trails.tunnel_cli._start_http_runtime") as start_http: + with patch("fava_trails.tunnel_cli._start_tunnel_client") as start_tunnel: + assert cmd_run(args) == 1 + + start_http.assert_not_called() + start_tunnel.assert_not_called() + + def test_run_checks_doctor_when_requested(tmp_path, monkeypatch): data_repo = _make_data_repo(tmp_path) monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") @@ -730,6 +749,77 @@ def test_tunnel_cli_help_mentions_start(): help_text = parser.format_help() assert "start" in help_text assert "run" in help_text + assert "preflight" in help_text + + +def test_preflight_starts_only_private_runtime_then_cleans_up(tmp_path, monkeypatch): + data_repo = _make_data_repo(tmp_path) + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") + args = _args(data_repo=str(data_repo), tunnel_doctor=True) + http_process = MagicMock(pid=111) + http_process.poll.return_value = None + + with patch("fava_trails.tunnel_cli._find_jj_bin", return_value="/usr/bin/jj"): + with patch("shutil.which", return_value="/usr/bin/tunnel-client"): + with patch("fava_trails.tunnel_cli._check_port_available"): + with patch("fava_trails.tunnel_cli._start_http_runtime", return_value=http_process) as start_http: + with patch("fava_trails.tunnel_cli._wait_for_health") as wait_health: + with patch("fava_trails.tunnel_cli._run_tunnel_doctor") as doctor: + with patch("fava_trails.tunnel_cli._start_tunnel_client") as start_tunnel: + with patch("fava_trails.tunnel_cli._terminate_process") as terminate: + assert cmd_preflight(args) == 0 + + start_http.assert_called_once() + wait_health.assert_called_once() + doctor.assert_called_once() + start_tunnel.assert_not_called() + terminate.assert_called_once_with(http_process) + + +def test_preflight_cleans_private_runtime_when_doctor_fails(tmp_path, monkeypatch): + data_repo = _make_data_repo(tmp_path) + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") + args = _args(data_repo=str(data_repo), tunnel_doctor=True) + http_process = MagicMock(pid=111) + http_process.poll.return_value = None + + with patch("fava_trails.tunnel_cli._find_jj_bin", return_value="/usr/bin/jj"): + with patch("shutil.which", return_value="/usr/bin/tunnel-client"): + with patch("fava_trails.tunnel_cli._check_port_available"): + with patch("fava_trails.tunnel_cli._start_http_runtime", return_value=http_process): + with patch("fava_trails.tunnel_cli._wait_for_health"): + with patch( + "fava_trails.tunnel_cli._run_tunnel_doctor", + side_effect=subprocess.SubprocessError("doctor failed"), + ): + with patch("fava_trails.tunnel_cli._start_tunnel_client") as start_tunnel: + with patch("fava_trails.tunnel_cli._terminate_process") as terminate: + assert cmd_preflight(args) == 1 + + start_tunnel.assert_not_called() + terminate.assert_called_once_with(http_process) + + +def test_preflight_cleans_private_runtime_when_readiness_fails(tmp_path, monkeypatch): + data_repo = _make_data_repo(tmp_path) + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") + args = _args(data_repo=str(data_repo)) + http_process = MagicMock(pid=111) + http_process.poll.return_value = None + + with patch("fava_trails.tunnel_cli._find_jj_bin", return_value="/usr/bin/jj"): + with patch("fava_trails.tunnel_cli._check_port_available"): + with patch("fava_trails.tunnel_cli._start_http_runtime", return_value=http_process): + with patch( + "fava_trails.tunnel_cli._wait_for_health", + side_effect=TimeoutError("not ready"), + ): + with patch("fava_trails.tunnel_cli._start_tunnel_client") as start_tunnel: + with patch("fava_trails.tunnel_cli._terminate_process") as terminate: + assert cmd_preflight(args) == 1 + + start_tunnel.assert_not_called() + terminate.assert_called_once_with(http_process) def _health_response(data_repo: Path, monkeypatch):