Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
20 changes: 20 additions & 0 deletions examples/tunnel-gateway/README.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions examples/tunnel-gateway/compose.yaml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 33 additions & 3 deletions src/fava_trails/tunnel_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
90 changes: 90 additions & 0 deletions tests/test_tunnel_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

Expand All @@ -22,6 +23,7 @@
DEFAULT_PROFILE,
_load_gateway_config,
_runtime_env,
cmd_preflight,
cmd_run,
cmd_start,
cmd_status,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down
Loading