feat: enhance service discovery and proxy management
This commit is contained in:
@@ -52,6 +52,38 @@ def test_list_proxies_returns_documents(client, monkeypatch: object):
|
||||
assert [item["proxy_port"] for item in body] == [8080, 8081]
|
||||
|
||||
|
||||
def test_list_services_uses_query_scan_root(client, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_get_all_services_in_dir(root):
|
||||
captured["root"] = str(root)
|
||||
return [
|
||||
{
|
||||
"name": "echo",
|
||||
"ports": [9081],
|
||||
"bindings": [],
|
||||
"folder": "/root/play",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_all_services_in_dir",
|
||||
fake_get_all_services_in_dir,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/services", params={"scan_root": "/root"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"scan_root": "/root",
|
||||
"services": [
|
||||
{"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"}
|
||||
],
|
||||
}
|
||||
assert captured["root"] == "/root"
|
||||
|
||||
|
||||
def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> None:
|
||||
return None
|
||||
|
||||
@@ -13,7 +13,7 @@ os.environ.setdefault("MONGODB_DB_NAME", "theplug")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
|
||||
os.environ.setdefault("JWT_ALGORITHM", "HS256")
|
||||
os.environ.setdefault("JWT_EXPIRE_MINUTES", "30")
|
||||
os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
|
||||
os.environ.setdefault("AUTH_USERNAME", "admin")
|
||||
os.environ.setdefault("AUTH_PASSWORD", "admin")
|
||||
|
||||
@@ -33,7 +33,7 @@ def auth_context(fake_user):
|
||||
from core.security import AuthContext
|
||||
from models.auth import AccessTokenPayload
|
||||
|
||||
exp = int((datetime.now(UTC) + timedelta(minutes=30)).timestamp())
|
||||
exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
|
||||
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
|
||||
return AuthContext(token="test-token", user=fake_user, payload=payload)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from pathlib import Path
|
||||
|
||||
from integrations.docker.compose import get_all_services_in_dir
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_scans_immediate_children(tmp_path: Path):
|
||||
root = tmp_path / "workspace"
|
||||
project = root / "playground"
|
||||
project.mkdir(parents=True)
|
||||
(project / "docker-compose.yml").write_text(
|
||||
"""
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
services = get_all_services_in_dir(root)
|
||||
|
||||
assert services == [
|
||||
{
|
||||
"name": "echo",
|
||||
"ports": [9081],
|
||||
"bindings": [{"published": 9081, "host": None}],
|
||||
"folder": project,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_skips_nested_grandchildren(tmp_path: Path):
|
||||
root = tmp_path / "workspace"
|
||||
nested = root / "group" / "playground"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "docker-compose.yml").write_text(
|
||||
"""
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
services = get_all_services_in_dir(root)
|
||||
|
||||
assert services == []
|
||||
@@ -0,0 +1,30 @@
|
||||
from integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
|
||||
|
||||
|
||||
def test_get_service_binding_includes_loopback_hosts():
|
||||
assert _get_service_binding("127.0.0.1:8080:80") == (8080, "127.0.0.1")
|
||||
assert _get_service_binding("localhost:9081:5678") == (9081, "localhost")
|
||||
|
||||
|
||||
def test_replace_port_in_mapping_can_force_loopback_host():
|
||||
updated = _replace_port_in_mapping(
|
||||
"9081:5678",
|
||||
9081,
|
||||
54001,
|
||||
host_to="127.0.0.1",
|
||||
rewrite_host=True,
|
||||
)
|
||||
|
||||
assert updated == "127.0.0.1:54001:5678"
|
||||
|
||||
|
||||
def test_replace_port_in_mapping_can_restore_unspecified_host():
|
||||
restored = _replace_port_in_mapping(
|
||||
"127.0.0.1:54001:5678",
|
||||
54001,
|
||||
9081,
|
||||
host_to=None,
|
||||
rewrite_host=True,
|
||||
)
|
||||
|
||||
assert restored == "9081:5678"
|
||||
@@ -73,3 +73,97 @@ def test_running_document_without_pid_is_treated_as_stopped():
|
||||
return await manager.status(proxy)
|
||||
|
||||
assert asyncio.run(run()) == "stopped"
|
||||
|
||||
|
||||
def test_matches_proxy_process_uses_expected_script_and_confdir():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy()
|
||||
|
||||
manager.read_cmdline = lambda _pid: [
|
||||
"/home/test/.venv/bin/python",
|
||||
"/home/test/.venv/bin/mitmdump",
|
||||
"--mode",
|
||||
"reverse:http://127.0.0.1:9000@8080",
|
||||
"--listen-host",
|
||||
"0.0.0.0",
|
||||
"--set",
|
||||
"confdir=/tmp/theplug/conf/8080",
|
||||
"-s",
|
||||
"/tmp/theplug/addons/8080.py",
|
||||
]
|
||||
|
||||
assert manager.matches_proxy_process(proxy, 1234) is True
|
||||
|
||||
|
||||
def test_reconcile_reattaches_matching_process():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
status="running",
|
||||
pid=4321,
|
||||
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
||||
)
|
||||
manager.is_process_alive = lambda _pid: True
|
||||
manager.matches_proxy_process = lambda _proxy, _pid: True
|
||||
|
||||
async def run():
|
||||
return await manager.reconcile(proxy)
|
||||
|
||||
values = asyncio.run(run())
|
||||
|
||||
assert values is not None
|
||||
assert values["status"] == "running"
|
||||
assert values["pid"] == 4321
|
||||
assert manager.attached_pids[8080] == 4321
|
||||
|
||||
|
||||
def test_reconcile_marks_error_for_mismatched_live_process():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
status="running",
|
||||
pid=4321,
|
||||
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
||||
)
|
||||
manager.is_process_alive = lambda _pid: True
|
||||
manager.matches_proxy_process = lambda _proxy, _pid: False
|
||||
|
||||
async def run():
|
||||
return await manager.reconcile(proxy)
|
||||
|
||||
values = asyncio.run(run())
|
||||
|
||||
assert values is not None
|
||||
assert values["status"] == "error"
|
||||
assert values["pid"] is None
|
||||
assert "does not match" in str(values["last_error"])
|
||||
|
||||
|
||||
def test_reconcile_restores_upstream_when_process_is_gone():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
status="running",
|
||||
pid=4321,
|
||||
service=ServiceEndpointDocument(
|
||||
port=9000,
|
||||
upstream_port=54003,
|
||||
name="echo",
|
||||
folder="/tmp/playground",
|
||||
),
|
||||
)
|
||||
manager.is_process_alive = lambda _pid: False
|
||||
|
||||
async def fake_restore(_proxy, _upstream_port):
|
||||
return True
|
||||
|
||||
manager.restore_upstream = fake_restore
|
||||
|
||||
async def run():
|
||||
return await manager.reconcile(proxy)
|
||||
|
||||
values = asyncio.run(run())
|
||||
|
||||
assert values == {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import asyncio
|
||||
|
||||
from models.proxy import ProxyDocument
|
||||
from models.service import ServiceEndpointDocument
|
||||
from services.mitmproxy import recovery as recovery_module
|
||||
|
||||
|
||||
def make_proxy(**updates):
|
||||
data = {
|
||||
"proxy_port": 8080,
|
||||
"name": "svc",
|
||||
"code": "",
|
||||
"service": ServiceEndpointDocument(port=9000),
|
||||
}
|
||||
data.update(updates)
|
||||
return ProxyDocument(**data)
|
||||
|
||||
|
||||
def test_reconcile_runtime_persists_manager_updates(monkeypatch):
|
||||
proxy = make_proxy(status="running", pid=1234)
|
||||
captured: list[tuple[int, dict[str, object]]] = []
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [proxy]
|
||||
|
||||
class FakeManager:
|
||||
async def reconcile(self, document: ProxyDocument):
|
||||
assert document.proxy_port == 8080
|
||||
return {"status": "running", "pid": 1234, "last_error": None}
|
||||
|
||||
async def fake_update_proxy_runtime(proxy_port: int, values: dict[str, object]):
|
||||
captured.append((proxy_port, values))
|
||||
return proxy
|
||||
|
||||
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
||||
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
recovery_module,
|
||||
"update_proxy_runtime",
|
||||
fake_update_proxy_runtime,
|
||||
)
|
||||
|
||||
changes = asyncio.run(recovery_module.reconcile_runtime())
|
||||
|
||||
assert changes == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
||||
assert captured == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
||||
|
||||
|
||||
def test_reconcile_runtime_skips_unchanged_proxies(monkeypatch):
|
||||
proxy = make_proxy()
|
||||
updated = False
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [proxy]
|
||||
|
||||
class FakeManager:
|
||||
async def reconcile(self, _document: ProxyDocument):
|
||||
return None
|
||||
|
||||
async def fake_update_proxy_runtime(_proxy_port: int, _values: dict[str, object]):
|
||||
nonlocal updated
|
||||
updated = True
|
||||
return proxy
|
||||
|
||||
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
||||
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
recovery_module,
|
||||
"update_proxy_runtime",
|
||||
fake_update_proxy_runtime,
|
||||
)
|
||||
|
||||
changes = asyncio.run(recovery_module.reconcile_runtime())
|
||||
|
||||
assert changes == []
|
||||
assert updated is False
|
||||
@@ -0,0 +1,16 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.security import create_access_token, decode_access_token
|
||||
|
||||
|
||||
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
|
||||
monkeypatch.setattr("core.security.settings.JWT_EXPIRE_MINUTES", 720)
|
||||
|
||||
issued_at = datetime.now(UTC)
|
||||
token = create_access_token(subject="admin")
|
||||
payload = decode_access_token(token)
|
||||
expires_at = datetime.fromtimestamp(payload.exp, UTC)
|
||||
|
||||
lifetime_seconds = (expires_at - issued_at).total_seconds()
|
||||
|
||||
assert 11.9 * 60 * 60 <= lifetime_seconds <= 12.1 * 60 * 60
|
||||
Reference in New Issue
Block a user