generated from coulomb/repo-seed
Fixing bridge to haskelseed
This commit is contained in:
7
.codex/config.toml
Normal file
7
.codex/config.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[mcp_servers.ops-bridge]
|
||||||
|
command = "uv"
|
||||||
|
args = [
|
||||||
|
"run",
|
||||||
|
"python",
|
||||||
|
"src/bridge/mcp_server/server.py",
|
||||||
|
]
|
||||||
19
Makefile
19
Makefile
@@ -1,13 +1,22 @@
|
|||||||
.PHONY: test lint install mcp-http mcp-stop
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
test:
|
.PHONY: help setup test lint install mcp-http mcp-stop
|
||||||
|
|
||||||
|
help: ## List available make targets
|
||||||
|
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-16s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
setup: ## Sync dependencies and install the bridge CLI wrapper
|
||||||
|
uv sync --all-groups
|
||||||
|
uv tool install -e . --force
|
||||||
|
|
||||||
|
test: ## Run the test suite
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
|
||||||
lint:
|
lint: ## Run ruff lint checks
|
||||||
uv run ruff check .
|
uv run ruff check .
|
||||||
|
|
||||||
install:
|
install: ## Install the bridge CLI wrapper
|
||||||
uv tool install -e .
|
uv tool install -e . --force
|
||||||
|
|
||||||
mcp-http: ## Start MCP server in SSE mode (default port 8002)
|
mcp-http: ## Start MCP server in SSE mode (default port 8002)
|
||||||
BRIDGE_MCP_PORT=$${BRIDGE_MCP_PORT:-8002} uv run python src/bridge/mcp_server/server.py --http
|
BRIDGE_MCP_PORT=$${BRIDGE_MCP_PORT:-8002} uv run python src/bridge/mcp_server/server.py --http
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""End-to-end tunnel diagnostics for OpsBridge."""
|
"""End-to-end tunnel diagnostics for OpsBridge."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -13,6 +14,38 @@ from bridge.models import BridgeState, TunnelConfig
|
|||||||
from bridge.state import StateManager, _pid_alive
|
from bridge.state import StateManager, _pid_alive
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_port_probe_command(remote_port: int) -> str:
|
||||||
|
"""Build a portable remote shell probe for a listening TCP port."""
|
||||||
|
return (
|
||||||
|
f"port={remote_port}; "
|
||||||
|
"if command -v ss >/dev/null 2>&1; then "
|
||||||
|
"ss -tnlp 2>/dev/null | grep -q \":$port \" && echo ok || echo closed; "
|
||||||
|
"elif command -v netstat >/dev/null 2>&1; then "
|
||||||
|
"netstat -tnlp 2>/dev/null | "
|
||||||
|
"grep -q \"[.:]$port[[:space:]]\" && echo ok || echo closed; "
|
||||||
|
"else "
|
||||||
|
"hex=$(printf '%04X' \"$port\"); "
|
||||||
|
"awk -v p=\":$hex\" "
|
||||||
|
"'NR > 1 && $4 == \"0A\" && index($2, p) { found = 1 } "
|
||||||
|
"END { print found ? \"ok\" : \"closed\" }' "
|
||||||
|
"/proc/net/tcp /proc/net/tcp6 2>/dev/null; "
|
||||||
|
"fi"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_local_port(local_port: int) -> str:
|
||||||
|
"""Check whether the local side of an SSH -L tunnel is accepting TCP."""
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", local_port), timeout=5):
|
||||||
|
return "listening"
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
return "closed"
|
||||||
|
except socket.timeout:
|
||||||
|
return "error:timeout"
|
||||||
|
except OSError as e:
|
||||||
|
return f"error:{e}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TunnelCheckResult:
|
class TunnelCheckResult:
|
||||||
tunnel: str
|
tunnel: str
|
||||||
@@ -52,35 +85,38 @@ def check_tunnel(cfg: TunnelConfig, state_mgr: StateManager) -> TunnelCheckResul
|
|||||||
and ssh_process != "ok"
|
and ssh_process != "ok"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. SSH probe for remote port
|
# 3. Port probe: reverse tunnels listen remotely; local tunnels listen here.
|
||||||
key_path = str(Path(cfg.ssh_key).expanduser())
|
if cfg.direction == "local":
|
||||||
cmd = [
|
remote_port = _probe_local_port(cfg.local_port)
|
||||||
"ssh",
|
else:
|
||||||
"-i", key_path,
|
key_path = str(Path(cfg.ssh_key).expanduser())
|
||||||
"-o", "BatchMode=yes",
|
cmd = [
|
||||||
"-o", "ConnectTimeout=5",
|
"ssh",
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-i", key_path,
|
||||||
f"{cfg.ssh_user}@{cfg.host}",
|
"-o", "BatchMode=yes",
|
||||||
f"ss -tnlp 2>/dev/null | grep -q ':{cfg.remote_port} ' && echo ok || echo closed",
|
"-o", "ConnectTimeout=5",
|
||||||
]
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
try:
|
f"{cfg.ssh_user}@{cfg.host}",
|
||||||
proc = subprocess.run(
|
_remote_port_probe_command(cfg.remote_port),
|
||||||
cmd,
|
]
|
||||||
capture_output=True,
|
try:
|
||||||
text=True,
|
proc = subprocess.run(
|
||||||
timeout=10,
|
cmd,
|
||||||
)
|
capture_output=True,
|
||||||
output = proc.stdout.strip()
|
text=True,
|
||||||
if output == "ok":
|
timeout=10,
|
||||||
remote_port = "listening"
|
)
|
||||||
elif output == "closed":
|
output = proc.stdout.strip()
|
||||||
remote_port = "closed"
|
if output == "ok":
|
||||||
else:
|
remote_port = "listening"
|
||||||
remote_port = f"error:{proc.stderr.strip() or 'unknown'}"
|
elif output == "closed":
|
||||||
except subprocess.TimeoutExpired:
|
remote_port = "closed"
|
||||||
remote_port = "error:timeout"
|
else:
|
||||||
except Exception as e:
|
remote_port = f"error:{proc.stderr.strip() or 'unknown'}"
|
||||||
remote_port = f"error:{e}"
|
except subprocess.TimeoutExpired:
|
||||||
|
remote_port = "error:timeout"
|
||||||
|
except Exception as e:
|
||||||
|
remote_port = f"error:{e}"
|
||||||
|
|
||||||
# 4. Local API health check (optional)
|
# 4. Local API health check (optional)
|
||||||
local_api: Optional[str] = None
|
local_api: Optional[str] = None
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bridge.diagnostics import check_all_tunnels, check_tunnel
|
from bridge.diagnostics import (
|
||||||
|
_remote_port_probe_command,
|
||||||
|
check_all_tunnels,
|
||||||
|
check_tunnel,
|
||||||
|
)
|
||||||
from bridge.models import BridgeState, TunnelConfig
|
from bridge.models import BridgeState, TunnelConfig
|
||||||
from bridge.state import StateManager
|
from bridge.state import StateManager
|
||||||
|
|
||||||
@@ -32,6 +36,14 @@ def state_mgr(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
class TestCheckTunnel:
|
class TestCheckTunnel:
|
||||||
|
def test_remote_port_probe_has_minimal_host_fallback(self):
|
||||||
|
"""Remote probe supports minimal hosts without ss/netstat."""
|
||||||
|
command = _remote_port_probe_command(18000)
|
||||||
|
assert "command -v ss" in command
|
||||||
|
assert "command -v netstat" in command
|
||||||
|
assert "/proc/net/tcp" in command
|
||||||
|
assert "/proc/net/tcp6" in command
|
||||||
|
|
||||||
def test_no_pid(self, tcfg, state_mgr):
|
def test_no_pid(self, tcfg, state_mgr):
|
||||||
"""No PID file → ssh_process='no_pid', ok=False."""
|
"""No PID file → ssh_process='no_pid', ok=False."""
|
||||||
with patch("bridge.diagnostics.subprocess.run") as mock_run:
|
with patch("bridge.diagnostics.subprocess.run") as mock_run:
|
||||||
@@ -83,6 +95,29 @@ class TestCheckTunnel:
|
|||||||
assert result.remote_port == "closed"
|
assert result.remote_port == "closed"
|
||||||
assert result.ok is False
|
assert result.ok is False
|
||||||
|
|
||||||
|
def test_local_direction_checks_local_port(self, tcfg, state_mgr):
|
||||||
|
"""Local tunnels verify the local listener instead of a remote -R port."""
|
||||||
|
local_cfg = TunnelConfig(
|
||||||
|
name="local-tunnel",
|
||||||
|
host="haskelseed.local",
|
||||||
|
remote_port=1234,
|
||||||
|
local_port=11234,
|
||||||
|
ssh_user="root",
|
||||||
|
ssh_key="~/.ssh/id_ops",
|
||||||
|
actor="adm-bernd",
|
||||||
|
direction="local",
|
||||||
|
)
|
||||||
|
state_mgr.write_pid("local-tunnel", 12345)
|
||||||
|
with (
|
||||||
|
patch("bridge.diagnostics._pid_alive", return_value=True),
|
||||||
|
patch("bridge.diagnostics._probe_local_port", return_value="listening"),
|
||||||
|
patch("bridge.diagnostics.subprocess.run") as mock_run,
|
||||||
|
):
|
||||||
|
result = check_tunnel(local_cfg, state_mgr)
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
assert result.remote_port == "listening"
|
||||||
|
assert result.ok is True
|
||||||
|
|
||||||
def test_ssh_timeout(self, tcfg, state_mgr):
|
def test_ssh_timeout(self, tcfg, state_mgr):
|
||||||
"""SSH probe timeout → remote_port='error:timeout'."""
|
"""SSH probe timeout → remote_port='error:timeout'."""
|
||||||
state_mgr.write_pid("test-tunnel", 12345)
|
state_mgr.write_pid("test-tunnel", 12345)
|
||||||
|
|||||||
56
workplans/ADHOC-2026-06-14.md
Normal file
56
workplans/ADHOC-2026-06-14.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
id: ADHOC-2026-06-14
|
||||||
|
type: workplan
|
||||||
|
title: "Ad hoc ops-bridge fixes for 2026-06-14"
|
||||||
|
domain: custodian
|
||||||
|
repo: ops-bridge
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: ops-bridge
|
||||||
|
created: "2026-06-14"
|
||||||
|
updated: "2026-06-14"
|
||||||
|
state_hub_workstream_id: "fbc2ef7e-626f-4c6a-bdf8-c69bf29097ce"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix haskelseed bridge diagnostics
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ADHOC-2026-06-14-T01
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "ffe6b8d8-889c-4ec4-8b64-00b77f86e39f"
|
||||||
|
```
|
||||||
|
|
||||||
|
`haskelseed` is an Alpine host without `ss`, so `bridge check` reported
|
||||||
|
reverse tunnel ports as closed even while SSH reverse listeners were present.
|
||||||
|
Updated diagnostics to fall back from `ss` to `netstat` and then
|
||||||
|
`/proc/net/tcp`/`tcp6`. Also fixed local-direction diagnostics so
|
||||||
|
`nix-daemon-haskelseed` checks the local `-L` listener instead of probing a
|
||||||
|
remote reverse port.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `state-hub-haskelseed` responded through `127.0.0.1:18000/state/health`.
|
||||||
|
- `bridge check --json` reported all configured tunnels `ok: true`.
|
||||||
|
- `python3 -m pytest tests/test_cli.py tests/test_diagnostics.py` passed.
|
||||||
|
|
||||||
|
## Make default target safe and add setup
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ADHOC-2026-06-14-T02
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "3b932955-0d75-4b95-9821-92bfa2dadbd0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Changed `make` to default to a help listing that only shows targets with
|
||||||
|
`##` comments. Added `make setup` to run `uv sync --all-groups` and reinstall
|
||||||
|
the editable `bridge` CLI wrapper through `uv tool install -e . --force`.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `uv sync --all-groups` succeeded and installed the project environment.
|
||||||
|
- `make` listed targets only and did not run tests or setup.
|
||||||
|
- `make setup` succeeded and installed the `bridge` executable.
|
||||||
|
- `make test` passed all 235 tests.
|
||||||
|
- `make lint` passed.
|
||||||
Reference in New Issue
Block a user