9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
3373 lines
134 KiB
Python
3373 lines
134 KiB
Python
"""Tests for ods-host-agent.py — _parse_mem_value and _iso_now."""
|
|
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import types
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
import pytest
|
|
|
|
# Import the host agent module from bin/ using importlib.
|
|
# The module has an ``if __name__ == "__main__":`` guard so no server starts.
|
|
_agent_path = Path(__file__).resolve().parents[4] / "bin" / "ods-host-agent.py"
|
|
_spec = importlib.util.spec_from_file_location("ods_host_agent", _agent_path)
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
sys.modules["ods_host_agent"] = _mod
|
|
_spec.loader.exec_module(_mod)
|
|
|
|
_parse_mem_value = _mod._parse_mem_value
|
|
_iso_now = _mod._iso_now
|
|
_to_bash_path = _mod._to_bash_path
|
|
_resolve_agent_bind_addr = _mod._resolve_agent_bind_addr
|
|
resolve_compose_flags = _mod.resolve_compose_flags
|
|
validate_core_recreate_ids = _mod.validate_core_recreate_ids
|
|
invalidate_compose_cache = _mod.invalidate_compose_cache
|
|
_post_install_core_recreate = _mod._post_install_core_recreate
|
|
_split_nmcli_terse = _mod._split_nmcli_terse
|
|
_request_server_shutdown = _mod._request_server_shutdown
|
|
|
|
|
|
def can_create_symlinks(tmp_path: Path) -> bool:
|
|
target = tmp_path / "symlink-target"
|
|
link = tmp_path / "symlink-probe"
|
|
target.mkdir()
|
|
try:
|
|
link.symlink_to(target, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
return False
|
|
return link.is_symlink()
|
|
|
|
|
|
class TestHostAgentShutdown:
|
|
|
|
def test_signal_shutdown_runs_from_helper_thread(self, monkeypatch):
|
|
calls = []
|
|
|
|
class FakeServer:
|
|
def shutdown(self):
|
|
calls.append("shutdown")
|
|
|
|
class FakeThread:
|
|
def __init__(self, target, name=None, daemon=None):
|
|
calls.append(("thread", name, daemon))
|
|
self._target = target
|
|
|
|
def start(self):
|
|
calls.append("start")
|
|
self._target()
|
|
|
|
monkeypatch.setattr(_mod.threading, "Thread", FakeThread)
|
|
|
|
_request_server_shutdown(FakeServer(), signum=15)
|
|
|
|
assert ("thread", "ods-host-agent-shutdown", True) in calls
|
|
assert "start" in calls
|
|
assert "shutdown" in calls
|
|
|
|
|
|
class TestResolveAgentBindAddr:
|
|
|
|
def test_explicit_bind_wins(self):
|
|
assert _resolve_agent_bind_addr({"ODS_AGENT_BIND": "0.0.0.0"}, "Linux") == "0.0.0.0"
|
|
assert _resolve_agent_bind_addr({"ODS_AGENT_BIND": "192.168.1.10"}, "Linux") == "192.168.1.10"
|
|
|
|
def test_desktop_platforms_default_loopback(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "_detect_docker_network_gateway", lambda network: "172.18.0.1")
|
|
monkeypatch.setattr(_mod, "_detect_docker_bridge_gateway", lambda: "172.17.0.1")
|
|
|
|
assert _resolve_agent_bind_addr({}, "Windows") == "127.0.0.1"
|
|
assert _resolve_agent_bind_addr({}, "Darwin") == "127.0.0.1"
|
|
|
|
def test_linux_prefers_ods_network_gateway(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "_detect_docker_network_gateway", lambda network: "172.18.0.1")
|
|
monkeypatch.setattr(_mod, "_detect_docker_bridge_gateway", lambda: "172.17.0.1")
|
|
|
|
assert _resolve_agent_bind_addr({}, "Linux") == "172.18.0.1"
|
|
|
|
def test_linux_falls_back_to_bridge_gateway(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "_detect_docker_network_gateway", lambda network: "")
|
|
monkeypatch.setattr(_mod, "_detect_docker_bridge_gateway", lambda: "172.17.0.1")
|
|
|
|
assert _resolve_agent_bind_addr({}, "Linux") == "172.17.0.1"
|
|
|
|
def test_linux_falls_back_to_loopback(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "_detect_docker_network_gateway", lambda network: "")
|
|
monkeypatch.setattr(_mod, "_detect_docker_bridge_gateway", lambda: "")
|
|
|
|
assert _resolve_agent_bind_addr({}, "Linux") == "127.0.0.1"
|
|
|
|
|
|
class TestResolveComposeFlags:
|
|
|
|
def test_windows_passes_host_python_to_bash_resolver(self, tmp_path, monkeypatch):
|
|
install_dir = tmp_path / "ods"
|
|
scripts_dir = install_dir / "scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "resolve-compose-stack.sh").write_text("#!/usr/bin/env bash\n")
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "TIER", "1")
|
|
monkeypatch.setattr(_mod, "GPU_BACKEND", "nvidia")
|
|
monkeypatch.setattr(_mod, "GPU_COUNT", "1")
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
_mod.sys,
|
|
"executable",
|
|
r"C:\Users\odser\AppData\Local\Programs\Python\Python313\python.exe",
|
|
)
|
|
monkeypatch.setenv("ODS_PYTHON_CMD", "python3")
|
|
git_bash = r"C:\Program Files\Git\bin\bash.exe"
|
|
monkeypatch.setattr(_mod, "_find_usable_bash", lambda: git_bash)
|
|
monkeypatch.setattr(_mod, "_ensure_windows_resolver_pyyaml", lambda python_cmd: None)
|
|
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append((cmd, kwargs))
|
|
return subprocess.CompletedProcess(
|
|
args=cmd,
|
|
returncode=0,
|
|
stdout="-f docker-compose.base.yml\n",
|
|
stderr="",
|
|
)
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
assert resolve_compose_flags() == ["-f", "docker-compose.base.yml"]
|
|
assert calls
|
|
assert calls[0][0][0] == git_bash
|
|
env = calls[0][1]["env"]
|
|
assert env["ODS_PYTHON_CMD"] == (
|
|
"/c/Users/odser/AppData/Local/Programs/Python/Python313/python.exe"
|
|
)
|
|
|
|
def test_windows_installs_pyyaml_for_resolver_python(self, monkeypatch):
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Windows")
|
|
python_cmd = r"C:\Users\odser\AppData\Local\Programs\Python\Python312\python.exe"
|
|
calls = []
|
|
import_attempts = {"count": 0}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append(cmd)
|
|
if cmd == [python_cmd, "-c", "import yaml"]:
|
|
import_attempts["count"] += 1
|
|
return subprocess.CompletedProcess(
|
|
cmd,
|
|
0 if import_attempts["count"] > 1 else 1,
|
|
"",
|
|
"" if import_attempts["count"] > 1 else "ModuleNotFoundError",
|
|
)
|
|
if cmd[:4] == [python_cmd, "-m", "pip", "install"]:
|
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
process_attempts = {"count": 0}
|
|
|
|
def fake_process_can_import(module):
|
|
assert module == "yaml"
|
|
process_attempts["count"] += 1
|
|
return True
|
|
|
|
monkeypatch.setattr(_mod, "_process_can_import", fake_process_can_import)
|
|
|
|
_mod._ensure_windows_resolver_pyyaml(python_cmd)
|
|
|
|
assert [python_cmd, "-m", "pip", "install"] == calls[1][:4]
|
|
assert "--user" in calls[1]
|
|
assert "PyYAML" in calls[1]
|
|
assert import_attempts["count"] == 2
|
|
assert process_attempts["count"] == 1
|
|
|
|
def test_windows_pyyaml_check_verifies_running_process_import(self, monkeypatch):
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Windows")
|
|
python_cmd = r"C:\Users\odser\AppData\Local\Programs\Python\Python312\python.exe"
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append(cmd)
|
|
if cmd == [python_cmd, "-c", "import yaml"]:
|
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
|
if cmd[:4] == [python_cmd, "-m", "pip", "install"]:
|
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
process_results = iter([False, True])
|
|
|
|
def fake_process_can_import(module):
|
|
assert module == "yaml"
|
|
return next(process_results)
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(_mod, "_process_can_import", fake_process_can_import)
|
|
|
|
_mod._ensure_windows_resolver_pyyaml(python_cmd)
|
|
|
|
assert [python_cmd, "-m", "pip", "install"] == calls[1][:4]
|
|
|
|
def test_windows_bash_discovery_skips_unusable_path_bash(self, monkeypatch):
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(_mod.shutil, "which", lambda name: r"C:\Windows\System32\bash.exe")
|
|
monkeypatch.setattr(_mod, "_update_usable_bash", None)
|
|
monkeypatch.setattr(_mod, "_usable_bash", None)
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append(cmd)
|
|
if cmd[0] == r"C:\Program Files\Git\bin\bash.exe":
|
|
return subprocess.CompletedProcess(cmd, 0, "ok", "")
|
|
return subprocess.CompletedProcess(cmd, 1, "", "WSL has no distro")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
assert _mod._find_update_bash() == r"C:\Program Files\Git\bin\bash.exe"
|
|
assert calls[0][0] == r"C:\Windows\System32\bash.exe"
|
|
assert calls[-1][0] == r"C:\Program Files\Git\bin\bash.exe"
|
|
|
|
|
|
# --- _split_nmcli_terse — parser for nmcli -t (terse) output ---
|
|
|
|
|
|
class TestSplitNmcliTerse:
|
|
"""Reviewer flagged `line.split(':')` as fragile for SSIDs / connection
|
|
names containing ':' (#1159 audit, point 3). `_split_nmcli_terse` is
|
|
the replacement that respects nmcli's documented `\\:` escaping in
|
|
terse mode.
|
|
"""
|
|
|
|
def test_empty_line(self):
|
|
assert _split_nmcli_terse("") == []
|
|
|
|
def test_simple_four_fields(self):
|
|
# SSID:SIGNAL:SECURITY:IN-USE from `nmcli -t -f ... device wifi list`
|
|
assert _split_nmcli_terse("HomeWiFi:88:WPA2:*") == ["HomeWiFi", "88", "WPA2", "*"]
|
|
|
|
def test_trailing_empty_field(self):
|
|
# IN-USE is empty when this row isn't the connected network.
|
|
assert _split_nmcli_terse("Guest:50:WPA2:") == ["Guest", "50", "WPA2", ""]
|
|
|
|
def test_ssid_with_escaped_colon(self):
|
|
# SSID literally named "Cafe:Lounge" comes back as "Cafe\:Lounge"
|
|
# under default nmcli -t escaping. Naive str.split(':') corrupts it.
|
|
assert _split_nmcli_terse(r"Cafe\:Lounge:67:WPA2:") == ["Cafe:Lounge", "67", "WPA2", ""]
|
|
|
|
def test_connection_name_with_escaped_backslash(self):
|
|
# Backslashes also escaped (as '\\\\') in terse mode.
|
|
assert _split_nmcli_terse(r"home\\net:wifi:connected:Home") == ["home\\net", "wifi", "connected", "Home"]
|
|
|
|
def test_multiple_escaped_colons_in_one_field(self):
|
|
# SSID containing multiple colons.
|
|
assert _split_nmcli_terse(r"a\:b\:c:1:open:") == ["a:b:c", "1", "open", ""]
|
|
|
|
def test_no_unescaped_colons_returns_one_part(self):
|
|
assert _split_nmcli_terse("solo") == ["solo"]
|
|
|
|
|
|
class TestNetworkHandlers:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _network_env(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr(_mod.shutil, "which", lambda name: f"/usr/bin/{name}" if name == "nmcli" else None)
|
|
|
|
def test_wifi_scan_keeps_strongest_duplicate_ssid(self, monkeypatch):
|
|
def fake_run(cmd, *args, **kwargs):
|
|
if cmd[:4] == ["nmcli", "device", "wifi", "rescan"]:
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
|
if cmd[:3] == ["nmcli", "-t", "-f"]:
|
|
stdout = "\n".join([
|
|
"Cafe:20:WPA2:",
|
|
"Cafe:80:WPA2:*",
|
|
"Guest:40::",
|
|
])
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout=stdout, stderr="")
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
handler = _FakeHandler(b"")
|
|
|
|
_mod.AgentHandler._handle_network_wifi_scan(handler)
|
|
|
|
assert handler.response_code == 200
|
|
body = handler.parse_response()
|
|
assert body["networks"][0] == {
|
|
"ssid": "Cafe",
|
|
"signal": 80,
|
|
"security": "WPA2",
|
|
"in_use": True,
|
|
}
|
|
assert body["networks"][1]["ssid"] == "Guest"
|
|
|
|
def test_wifi_forget_refuses_non_wifi_profile(self, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
calls.append(cmd)
|
|
if cmd[:3] == ["nmcli", "-t", "-f"]:
|
|
return subprocess.CompletedProcess(
|
|
args=cmd,
|
|
returncode=0,
|
|
stdout="connection.type:802-3-ethernet\n",
|
|
stderr="",
|
|
)
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
handler = _FakeHandler(json.dumps({"connection": "Wired"}).encode("utf-8"))
|
|
|
|
_mod.AgentHandler._handle_network_wifi_forget(handler)
|
|
|
|
assert handler.response_code == 400
|
|
assert "non-Wi-Fi" in handler.parse_response()["error"]
|
|
assert all(cmd[:3] != ["nmcli", "connection", "delete"] for cmd in calls)
|
|
|
|
def test_network_status_reports_nmcli_status_failure_as_unsupported(self, monkeypatch):
|
|
def fake_run(cmd, *args, **kwargs):
|
|
if cmd[:3] == ["nmcli", "-t", "-f"]:
|
|
return subprocess.CompletedProcess(
|
|
args=cmd,
|
|
returncode=8,
|
|
stdout="",
|
|
stderr="NetworkManager is not running",
|
|
)
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
handler = _FakeHandler(b"")
|
|
|
|
_mod.AgentHandler._handle_network_status(handler)
|
|
|
|
assert handler.response_code == 200
|
|
body = handler.parse_response()
|
|
assert body["platform_supported"] is False
|
|
assert "NetworkManager is not running" in body["reason"]
|
|
|
|
|
|
# --- _parse_mem_value ---
|
|
|
|
|
|
class TestParseMemValue:
|
|
|
|
def test_mib(self):
|
|
assert _parse_mem_value("256MiB") == 256.0
|
|
|
|
def test_gib(self):
|
|
assert _parse_mem_value("4GiB") == 4096.0
|
|
|
|
def test_tib(self):
|
|
assert _parse_mem_value("1TiB") == 1024 * 1024
|
|
|
|
def test_kib(self):
|
|
assert _parse_mem_value("512KiB") == 0.5
|
|
|
|
def test_bytes(self):
|
|
result = _parse_mem_value("1024B")
|
|
assert abs(result - 1024 / (1024 * 1024)) < 1e-9
|
|
|
|
def test_fractional_gib(self):
|
|
assert _parse_mem_value("1.5GiB") == 1536.0
|
|
|
|
def test_zero_bytes(self):
|
|
assert _parse_mem_value("0B") == 0.0
|
|
|
|
def test_dash_dash(self):
|
|
assert _parse_mem_value("--") == 0.0
|
|
|
|
def test_empty_string(self):
|
|
assert _parse_mem_value("") == 0.0
|
|
|
|
def test_invalid_number(self):
|
|
assert _parse_mem_value("xyzMiB") == 0.0
|
|
|
|
def test_whitespace_padding(self):
|
|
assert _parse_mem_value(" 256MiB ") == 256.0
|
|
|
|
|
|
# --- _iso_now ---
|
|
|
|
|
|
class TestIsoNow:
|
|
|
|
def test_returns_utc_iso_string(self):
|
|
result = _iso_now()
|
|
assert isinstance(result, str)
|
|
# UTC ISO strings end with +00:00
|
|
assert "+00:00" in result
|
|
|
|
def test_contains_t_separator(self):
|
|
result = _iso_now()
|
|
assert "T" in result
|
|
|
|
|
|
class TestToBashPath:
|
|
|
|
def test_leaves_posix_paths_unchanged(self, monkeypatch):
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Linux")
|
|
assert _to_bash_path(PurePosixPath("/opt/ods")) == "/opt/ods"
|
|
|
|
def test_converts_windows_drive_path(self, monkeypatch):
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Windows")
|
|
assert _to_bash_path(Path(r"C:\Users\Gabriel\ods")) == "/c/Users/Gabriel/ods"
|
|
|
|
|
|
class TestFindUsableBash:
|
|
|
|
def test_windows_skips_unusable_path_bash_and_uses_git_bash(self, monkeypatch):
|
|
bad_bash = r"C:\Windows\System32\bash.exe"
|
|
git_bash = r"C:\Program Files\Git\bin\bash.exe"
|
|
seen = []
|
|
|
|
monkeypatch.setattr(_mod, "_usable_bash", None)
|
|
monkeypatch.setattr(_mod.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(_mod.shutil, "which", lambda name: bad_bash if name == "bash" else None)
|
|
|
|
real_exists = _mod.Path.exists
|
|
|
|
def fake_exists(path):
|
|
if str(path) in {bad_bash, git_bash}:
|
|
return True
|
|
return real_exists(path)
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
seen.append(cmd[0])
|
|
if cmd[0] == bad_bash:
|
|
return subprocess.CompletedProcess(cmd, 127, "", "WSL execvpe(/bin/bash) failed")
|
|
if cmd[0] == git_bash:
|
|
return subprocess.CompletedProcess(cmd, 0, "ok", "")
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr(_mod.Path, "exists", fake_exists)
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
assert _mod._find_usable_bash() == git_bash
|
|
assert seen[:2] == [bad_bash, git_bash]
|
|
|
|
|
|
class TestValidateCoreRecreateIds:
|
|
|
|
def test_accepts_allowed_core_service(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", {"llama-server", "dashboard-api"})
|
|
ok, error = validate_core_recreate_ids(["llama-server"])
|
|
assert ok is True
|
|
assert error == ""
|
|
|
|
@pytest.mark.parametrize("service_id", ["hermes", "hermes-proxy"])
|
|
def test_accepts_hermes_core_recreate_services(self, monkeypatch, service_id):
|
|
monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", {service_id})
|
|
ok, error = validate_core_recreate_ids([service_id])
|
|
assert ok is True
|
|
assert error == ""
|
|
|
|
def test_rejects_non_core_service(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", {"dashboard-api"})
|
|
ok, error = validate_core_recreate_ids(["llama-server"])
|
|
assert ok is False
|
|
assert "not a core" in error.lower()
|
|
|
|
def test_rejects_disallowed_core_service(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", {"dashboard-api"})
|
|
ok, error = validate_core_recreate_ids(["dashboard-api"])
|
|
assert ok is False
|
|
assert "not eligible" in error.lower()
|
|
|
|
|
|
class TestResolveComposeFlagsCache:
|
|
|
|
def test_prefers_saved_compose_flags_file(self, tmp_path, monkeypatch):
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
(install_dir / ".compose-flags").write_text("--env-file .env -f docker-compose.base.yml", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
|
|
assert resolve_compose_flags() == ["--env-file", ".env", "-f", "docker-compose.base.yml"]
|
|
|
|
|
|
class TestComposeCacheInvalidationWire:
|
|
"""End-to-end HTTP test: dashboard-api client talks to the real host-agent handler."""
|
|
|
|
def test_client_posts_to_host_agent_and_unlinks_cache(self, tmp_path, monkeypatch):
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
from routers import extensions as ext_router
|
|
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
cache_file = install_dir / ".compose-flags"
|
|
cache_file.write_text("stale-flags", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
|
|
monkeypatch.setattr(ext_router, "ODS_AGENT_KEY", "wire-test-secret")
|
|
|
|
# Correct key → cache file is unlinked, helper returns without raising.
|
|
ext_router._call_agent_invalidate_compose_cache()
|
|
assert not cache_file.exists()
|
|
|
|
# Wrong key → handler rejects with 403, helper logs and returns; cache
|
|
# stays put. Proves the Authorization: Bearer <key> header is checked.
|
|
cache_file.write_text("stale-again", encoding="utf-8")
|
|
monkeypatch.setattr(ext_router, "ODS_AGENT_KEY", "wrong-secret")
|
|
ext_router._call_agent_invalidate_compose_cache()
|
|
assert cache_file.exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
class TestUpdateWire:
|
|
"""End-to-end HTTP tests for host-agent managed update actions."""
|
|
|
|
def test_check_runs_update_script_on_host_agent(self, tmp_path, monkeypatch):
|
|
import threading
|
|
import urllib.request
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
monkeypatch.setattr(
|
|
_mod,
|
|
"_run_update_script",
|
|
lambda action, *args, timeout: subprocess.CompletedProcess(
|
|
["ods-update", action, *args],
|
|
2,
|
|
"update available\n",
|
|
"",
|
|
),
|
|
)
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/v1/update/check",
|
|
data=b"{}",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer wire-test-secret",
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
|
|
assert resp.status == 200
|
|
assert data["success"] is True
|
|
assert data["update_available"] is True
|
|
assert data["returncode"] == 2
|
|
assert "update available" in data["output"]
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_start_records_background_update_status(self, tmp_path, monkeypatch):
|
|
import threading
|
|
import urllib.request
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
monkeypatch.setattr(
|
|
_mod,
|
|
"_run_update_script",
|
|
lambda action, *args, timeout: subprocess.CompletedProcess(
|
|
["ods-update", action, *args],
|
|
0,
|
|
"updated\n",
|
|
"",
|
|
),
|
|
)
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/v1/update/start",
|
|
data=b"{}",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer wire-test-secret",
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
accepted = json.loads(resp.read().decode("utf-8"))
|
|
|
|
assert resp.status == 202
|
|
assert accepted["status"] == "started"
|
|
|
|
deadline = time.time() + 2
|
|
status = {}
|
|
while time.time() < deadline:
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/v1/update/status",
|
|
headers={"Authorization": "Bearer wire-test-secret"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
status = json.loads(resp.read().decode("utf-8"))
|
|
if status.get("status") == "succeeded":
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
assert status["status"] == "succeeded"
|
|
assert status["returncode"] == 0
|
|
assert "updated" in status["output_tail"]
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_status_marks_stale_running_update_failed(self, tmp_path, monkeypatch):
|
|
import threading
|
|
import urllib.request
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
monkeypatch.setattr(_mod, "_update_thread", None)
|
|
_mod._write_update_status("running", "update", started_at="2026-01-01T00:00:00+00:00")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/v1/update/status",
|
|
headers={"Authorization": "Bearer wire-test-secret"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
status = json.loads(resp.read().decode("utf-8"))
|
|
|
|
assert resp.status == 200
|
|
assert status["status"] == "failed"
|
|
assert status["action"] == "update"
|
|
assert "before reporting completion" in status["error"]
|
|
assert _mod._read_update_status()["status"] == "failed"
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_start_records_unexpected_background_failure(self, tmp_path, monkeypatch):
|
|
import threading
|
|
import urllib.request
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
|
|
def fail_update(action, *args, timeout):
|
|
raise ValueError("boom")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
monkeypatch.setattr(_mod, "_update_thread", None)
|
|
monkeypatch.setattr(_mod, "_run_update_script", fail_update)
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/v1/update/start",
|
|
data=b"{}",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": "Bearer wire-test-secret",
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
accepted = json.loads(resp.read().decode("utf-8"))
|
|
|
|
assert resp.status == 202
|
|
assert accepted["status"] == "started"
|
|
|
|
deadline = time.time() + 2
|
|
status = {}
|
|
while time.time() < deadline:
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/v1/update/status",
|
|
headers={"Authorization": "Bearer wire-test-secret"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
status = json.loads(resp.read().decode("utf-8"))
|
|
if status.get("status") == "failed":
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
assert status["status"] == "failed"
|
|
assert "unexpectedly" in status["error"]
|
|
assert "boom" in status["error"]
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
class TestComposeToggleWire:
|
|
"""End-to-end HTTP test for built-in compose toggles via the host agent."""
|
|
|
|
def test_client_posts_to_host_agent_and_renames_builtin_compose(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
from routers import extensions as ext_router
|
|
|
|
builtin_root = tmp_path / "builtin"
|
|
user_root = tmp_path / "user"
|
|
builtin_root.mkdir()
|
|
user_root.mkdir()
|
|
ext_dir = builtin_root / "fakesvc"
|
|
ext_dir.mkdir()
|
|
(ext_dir / "compose.yaml.disabled").write_text(
|
|
"services:\n svc:\n image: test:latest\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
|
|
monkeypatch.setattr(ext_router, "ODS_AGENT_KEY", "wire-test-secret")
|
|
|
|
assert ext_router._call_agent_compose_rename("activate", "fakesvc") is True
|
|
assert (ext_dir / "compose.yaml").exists()
|
|
assert not (ext_dir / "compose.yaml.disabled").exists()
|
|
|
|
assert ext_router._call_agent_compose_rename("deactivate", "fakesvc") is True
|
|
assert (ext_dir / "compose.yaml.disabled").exists()
|
|
assert not (ext_dir / "compose.yaml").exists()
|
|
|
|
monkeypatch.setattr(ext_router, "ODS_AGENT_KEY", "wrong-secret")
|
|
assert ext_router._call_agent_compose_rename("activate", "fakesvc") is False
|
|
assert (ext_dir / "compose.yaml.disabled").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
class TestSyncExtensionConfigWire:
|
|
"""End-to-end HTTP test: dashboard-api client + real host-agent handler.
|
|
|
|
Proves that ${INSTALL_DIR}/config/<svc>/ ends up populated even though
|
|
the dashboard-api side only does an HTTP call (not filesystem work).
|
|
"""
|
|
|
|
def _make_extension(self, user_root, sid):
|
|
ext = user_root / sid
|
|
cfg = ext / "config" / sid
|
|
cfg.mkdir(parents=True)
|
|
(cfg / "settings.yaml").write_text("server: ok\n", encoding="utf-8")
|
|
(cfg / "entrypoint.sh").write_text("#!/bin/sh\necho run\n", encoding="utf-8")
|
|
return ext
|
|
|
|
def test_client_posts_and_host_agent_copies_config(self, tmp_path, monkeypatch):
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
from routers import extensions as ext_router
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
self._make_extension(user_root, "fakesvc")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
|
|
monkeypatch.setattr(ext_router, "ODS_AGENT_KEY", "wire-test-secret")
|
|
|
|
assert ext_router._call_agent_sync_config("fakesvc") is True
|
|
|
|
target = install_dir / "config" / "fakesvc"
|
|
assert (target / "settings.yaml").read_text(encoding="utf-8") == "server: ok\n"
|
|
# .sh files become executable
|
|
if os.name != "nt":
|
|
import stat as _s
|
|
mode = (target / "entrypoint.sh").stat().st_mode
|
|
assert mode & _s.S_IXUSR
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_noop_when_extension_has_no_config_subdir(self, tmp_path, monkeypatch):
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
from routers import extensions as ext_router
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
(user_root / "noconfig").mkdir() # no config/ subdir
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
|
|
monkeypatch.setattr(ext_router, "ODS_AGENT_KEY", "wire-test-secret")
|
|
|
|
# No config/ → server returns 200 with empty synced list, helper True.
|
|
assert ext_router._call_agent_sync_config("noconfig") is True
|
|
# No INSTALL_DIR/config/noconfig should have been created.
|
|
assert not (install_dir / "config" / "noconfig").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_rejects_wrong_auth(self, tmp_path, monkeypatch):
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
from routers import extensions as ext_router
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
self._make_extension(user_root, "fakesvc")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
monkeypatch.setattr(ext_router, "AGENT_URL", f"http://127.0.0.1:{port}")
|
|
monkeypatch.setattr(ext_router, "ODS_AGENT_KEY", "wrong-secret")
|
|
|
|
assert ext_router._call_agent_sync_config("fakesvc") is False
|
|
# Nothing copied — auth was rejected before any work.
|
|
assert not (install_dir / "config" / "fakesvc").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
@staticmethod
|
|
def _post(port, sid, *, key="wire-test-secret"):
|
|
"""Direct HTTP POST to /v1/extension/sync_config so callers can
|
|
assert on the raw status code (the dashboard-api helper masks
|
|
4xx as a generic False, which is too coarse for these tests)."""
|
|
import json as _json
|
|
import urllib.request
|
|
import urllib.error
|
|
url = f"http://127.0.0.1:{port}/v1/extension/sync_config"
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=_json.dumps({"service_id": sid}).encode(),
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {key}",
|
|
},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
return resp.status, _json.loads(resp.read() or b"{}")
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
body = _json.loads(exc.read() or b"{}")
|
|
except ValueError:
|
|
body = {}
|
|
return exc.code, body
|
|
|
|
def test_rejects_symlink_in_config_tree(self, tmp_path, monkeypatch):
|
|
"""Symlinks (file or directory, top-level or nested) must be rejected
|
|
outright. _copytree_safe strips symlinks at install time so legitimate
|
|
user extensions never have any; one here implies tampering."""
|
|
if os.name == "nt" and not can_create_symlinks(tmp_path):
|
|
pytest.skip("Windows symlink creation requires Developer Mode or administrator privileges")
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
secret_dir = tmp_path / "secret"
|
|
secret_dir.mkdir()
|
|
(secret_dir / "private.key").write_text("EXFIL ME", encoding="utf-8")
|
|
|
|
# Build an extension whose config/ contains a symlinked directory
|
|
# pointing OUTSIDE the extension. Pre-fix this got dereferenced by
|
|
# shutil.copytree(symlinks=False) and the secret leaked into
|
|
# INSTALL_DIR/config/leak/private.key.
|
|
ext = user_root / "fakesvc"
|
|
cfg = ext / "config"
|
|
cfg.mkdir(parents=True)
|
|
# Plus a normal file in the same tree to prove nothing was copied.
|
|
(cfg / "ok.yaml").write_text("ok: true\n", encoding="utf-8")
|
|
(cfg / "leak").symlink_to(secret_dir)
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
status, body = self._post(port, "fakesvc")
|
|
assert status == 400, f"expected 400, got {status}: {body}"
|
|
assert "symlink" in body.get("error", "").lower()
|
|
# Crucially: the secret file did NOT make it into INSTALL_DIR.
|
|
assert not (install_dir / "config" / "fakesvc").exists()
|
|
assert not (install_dir / "config" / "leak").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_rejects_symlinked_file_in_config_tree(self, tmp_path, monkeypatch):
|
|
"""Symlinked files (not just directories) are also rejected."""
|
|
if os.name == "nt" and not can_create_symlinks(tmp_path):
|
|
pytest.skip("Windows symlink creation requires Developer Mode or administrator privileges")
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
secret = tmp_path / "secret.txt"
|
|
secret.write_text("nope", encoding="utf-8")
|
|
|
|
ext = user_root / "fakesvc"
|
|
cfg = ext / "config" / "fakesvc"
|
|
cfg.mkdir(parents=True)
|
|
(cfg / "leak.txt").symlink_to(secret)
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
status, body = self._post(port, "fakesvc")
|
|
assert status == 400
|
|
assert "symlink" in body.get("error", "").lower()
|
|
assert not (install_dir / "config" / "fakesvc").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_rejects_when_config_dir_itself_is_symlink(self, tmp_path, monkeypatch):
|
|
"""Top-level `config/` itself a symlink — covers the upfront
|
|
ext_config.is_symlink() guard, separate from the dirs+files walk."""
|
|
if os.name == "nt" and not can_create_symlinks(tmp_path):
|
|
pytest.skip("Windows symlink creation requires Developer Mode or administrator privileges")
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
secret_dir = tmp_path / "secret"
|
|
secret_dir.mkdir()
|
|
(secret_dir / "private.key").write_text("EXFIL ME", encoding="utf-8")
|
|
|
|
# Build an extension whose `config` IS the symlink (not a child of it).
|
|
ext = user_root / "fakesvc"
|
|
ext.mkdir()
|
|
(ext / "config").symlink_to(secret_dir)
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
status, body = self._post(port, "fakesvc")
|
|
assert status == 400, f"expected 400, got {status}: {body}"
|
|
assert "symlink" in body.get("error", "").lower()
|
|
assert not (install_dir / "config" / "fakesvc").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_rejects_invalid_service_id(self, tmp_path, monkeypatch):
|
|
"""SERVICE_ID_RE rejection — match the auth/symlink reject style."""
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
for bad in ("../escape", "Bad-ID", "with space", "", "..", "FAKE"):
|
|
status, body = self._post(port, bad)
|
|
assert status == 400, f"bad={bad!r} -> {status}: {body}"
|
|
assert "service_id" in body.get("error", "").lower()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_noop_for_builtin_only_service(self, tmp_path, monkeypatch):
|
|
"""Built-in extensions (not in USER_EXTENSIONS_DIR) get a 200 no-op.
|
|
|
|
Pins the deliberate decision NOT to overwrite installer-managed
|
|
configs when a built-in's compose toggle re-enables it.
|
|
"""
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
builtin_root = tmp_path / "builtin"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
builtin_root.mkdir()
|
|
# Built-in present, with a config/ subdir that should NOT be touched.
|
|
builtin_ext = builtin_root / "core-svc" / "config" / "core-svc"
|
|
builtin_ext.mkdir(parents=True)
|
|
(builtin_ext / "should_not_be_synced.yaml").write_text("x: 1\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
status, body = self._post(port, "core-svc")
|
|
assert status == 200
|
|
assert body.get("synced") == []
|
|
# No file should have been written into INSTALL_DIR/config/.
|
|
assert not (install_dir / "config" / "core-svc").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
def test_default_contract_only_copies_own_service_subdir(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""Strict regression for the audit-flagged copy-contract weakness.
|
|
|
|
An extension shipping `<ext>/config/open-webui/` (or any other
|
|
sibling directory) must NOT have that tree copied into
|
|
`INSTALL_DIR/config/open-webui/` — that would let an extension
|
|
overwrite installer-managed core-service config (open-webui,
|
|
litellm, etc.) or another extension's config tree.
|
|
|
|
Default contract: only `<ext>/config/<service_id>/` is synced.
|
|
Sibling entries are logged as out-of-scope and reported in the
|
|
response's `skipped` array, but never written to INSTALL_DIR.
|
|
"""
|
|
import threading
|
|
from http.server import HTTPServer
|
|
|
|
install_dir = tmp_path / "install"
|
|
user_root = install_dir / "data" / "user-extensions"
|
|
install_dir.mkdir()
|
|
user_root.mkdir(parents=True)
|
|
|
|
# Build a malicious-shape extension: `evil-ext` that ships its OWN
|
|
# legitimate config subdir AND tries to overwrite open-webui's.
|
|
ext = user_root / "evil-ext"
|
|
own = ext / "config" / "evil-ext"
|
|
own.mkdir(parents=True)
|
|
(own / "settings.yaml").write_text("ok: true\n", encoding="utf-8")
|
|
|
|
clobber_target = ext / "config" / "open-webui"
|
|
clobber_target.mkdir(parents=True)
|
|
(clobber_target / "config.json").write_text(
|
|
"OVERWRITTEN", encoding="utf-8",
|
|
)
|
|
# Also a file directly under config/ (not in any subdir), proving the
|
|
# contract restriction applies to file siblings too.
|
|
(ext / "config" / "stray.txt").write_text("stray", encoding="utf-8")
|
|
|
|
# Pre-create open-webui core config so the test can prove byte-for-byte
|
|
# that it was NOT touched by the sync call.
|
|
existing_owui = install_dir / "config" / "open-webui"
|
|
existing_owui.mkdir(parents=True)
|
|
(existing_owui / "config.json").write_text(
|
|
"ORIGINAL", encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", tmp_path / "builtin-empty")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
status, body = self._post(port, "evil-ext")
|
|
assert status == 200
|
|
# In-scope copy succeeded.
|
|
assert body.get("synced") == ["evil-ext"]
|
|
assert (install_dir / "config" / "evil-ext" / "settings.yaml").read_text(
|
|
encoding="utf-8",
|
|
) == "ok: true\n"
|
|
# Out-of-scope entries reported (order not guaranteed).
|
|
skipped = set(body.get("skipped", []))
|
|
assert "open-webui" in skipped
|
|
assert "stray.txt" in skipped
|
|
# Crucially: open-webui core config remains BYTE-FOR-BYTE untouched.
|
|
assert (existing_owui / "config.json").read_text(
|
|
encoding="utf-8",
|
|
) == "ORIGINAL"
|
|
# The malicious overwrite payload did NOT escape into INSTALL_DIR.
|
|
assert not (install_dir / "config" / "stray.txt").exists()
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
class TestInvalidateComposeCache:
|
|
|
|
def test_unlinks_existing_cache_file(self, tmp_path, monkeypatch):
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
cache_file = install_dir / ".compose-flags"
|
|
cache_file.write_text("--env-file .env", encoding="utf-8")
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
|
|
invalidate_compose_cache()
|
|
|
|
assert not cache_file.exists()
|
|
|
|
def test_missing_cache_file_is_noop(self, tmp_path, monkeypatch):
|
|
install_dir = tmp_path / "ods"
|
|
install_dir.mkdir()
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
|
|
invalidate_compose_cache() # must not raise
|
|
|
|
|
|
# --- Install setup-hook env allowlist (regression) ---
|
|
#
|
|
# Locks in the fix that strips host-agent secrets from the env passed to
|
|
# extension setup hooks. The env-construction + subprocess.run call lives in
|
|
# the shared `_run_post_install_hook` helper (used by both _handle_install
|
|
# and _enable_retry_work). A source-level check is used because the helper
|
|
# is invoked from a nested closure on a daemon thread, which makes dynamic
|
|
# mocking fragile.
|
|
|
|
|
|
class TestInstallHookEnvAllowlist:
|
|
|
|
def _hook_helper_source(self):
|
|
import inspect
|
|
return inspect.getsource(_mod._run_post_install_hook)
|
|
|
|
def _install_source(self):
|
|
import inspect
|
|
return inspect.getsource(_mod.AgentHandler._handle_install)
|
|
|
|
def test_setup_hook_subprocess_run_passes_env_kwarg(self):
|
|
src = self._hook_helper_source()
|
|
assert "env=hook_env" in src, (
|
|
"setup_hook subprocess.run must pass env=hook_env "
|
|
"(regression: do not fall back to inheriting os.environ)"
|
|
)
|
|
|
|
def test_setup_hook_env_excludes_host_agent_secrets(self):
|
|
# Both the helper and the call-site must stay secret-free.
|
|
for src in (self._hook_helper_source(), self._install_source()):
|
|
for secret in ("AGENT_API_KEY", "ODS_AGENT_KEY", "DASHBOARD_API_KEY"):
|
|
assert secret not in src, (
|
|
f"setup_hook code path must not reference {secret}; "
|
|
"extension setup hooks must not receive host-agent secrets"
|
|
)
|
|
|
|
def test_setup_hook_env_contains_allowlist_keys(self):
|
|
src = self._hook_helper_source()
|
|
for key in (
|
|
"PATH", "HOME", "SERVICE_ID", "SERVICE_PORT",
|
|
"SERVICE_DATA_DIR", "ODS_VERSION", "GPU_BACKEND", "HOOK_NAME",
|
|
):
|
|
assert f'"{key}"' in src, (
|
|
f"setup_hook env allowlist missing required key {key}"
|
|
)
|
|
|
|
def test_setup_hook_uses_resolve_hook_with_post_install(self):
|
|
src = self._hook_helper_source()
|
|
assert '_resolve_hook(ext_dir, "post_install")' in src, (
|
|
"setup_hook must use _resolve_hook(..., 'post_install'); "
|
|
"the legacy _resolve_setup_hook has been removed"
|
|
)
|
|
|
|
|
|
# --- Install "up -d" must not use --no-deps (regression) ---
|
|
#
|
|
# _handle_install previously passed --no-deps to `docker compose up -d`, which
|
|
# prevented an extension's private sidecar services (declared in its own
|
|
# compose fragment) from starting — including cross-extension depends_on
|
|
# relationships like perplexica -> searxng. The fix removes --no-deps from
|
|
# the install path only; docker_compose_recreate (used for core-service
|
|
# force-recreate after a model swap) intentionally keeps --no-deps.
|
|
|
|
|
|
class TestInstallStartCommandNoDeps:
|
|
|
|
def _install_source(self):
|
|
import inspect
|
|
return inspect.getsource(_mod.AgentHandler._handle_install)
|
|
|
|
def _recreate_source(self):
|
|
import inspect
|
|
return inspect.getsource(_mod.docker_compose_recreate)
|
|
|
|
def test_install_up_command_does_not_pass_no_deps(self):
|
|
src = self._install_source()
|
|
assert '"--no-deps"' not in src and "'--no-deps'" not in src, (
|
|
"_handle_install must not pass --no-deps to `docker compose up -d`; "
|
|
"extensions with private sidecars or cross-extension depends_on "
|
|
"need compose to bring dependencies up."
|
|
)
|
|
|
|
def test_docker_compose_recreate_still_uses_no_deps(self):
|
|
src = self._recreate_source()
|
|
assert '"--no-deps"' in src or "'--no-deps'" in src, (
|
|
"docker_compose_recreate must keep --no-deps; "
|
|
"core-service recreation (e.g. after a model swap) is intentionally "
|
|
"scoped to the named services only."
|
|
)
|
|
|
|
|
|
# --- _post_install_core_recreate ---
|
|
#
|
|
# openclaw's compose.yaml adds OPENAI_API_BASE_URLS to open-webui as an overlay;
|
|
# `docker compose up -d openclaw` (used by _handle_install) won't pick up
|
|
# overlay changes targeting already-running core services without
|
|
# `--force-recreate`. Hence the post-install recreate of open-webui whenever
|
|
# openclaw is installed.
|
|
|
|
|
|
class TestPostInstallCoreRecreate:
|
|
|
|
def test_openclaw_triggers_open_webui_recreate(self, monkeypatch):
|
|
calls = []
|
|
|
|
def _fake_recreate(ids):
|
|
calls.append(list(ids))
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_recreate", _fake_recreate)
|
|
_post_install_core_recreate("openclaw")
|
|
assert calls == [["open-webui"]]
|
|
|
|
def test_non_openclaw_service_is_noop(self, monkeypatch):
|
|
calls = []
|
|
|
|
def _fake_recreate(ids):
|
|
calls.append(list(ids))
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_recreate", _fake_recreate)
|
|
for svc in ("litellm", "n8n", "perplexica", "whisper", "comfyui"):
|
|
_post_install_core_recreate(svc)
|
|
assert calls == []
|
|
|
|
def test_recreate_failure_is_swallowed(self, monkeypatch):
|
|
"""Install must not fail if the post-install recreate errors — openclaw
|
|
is already running; the overlay just won't take effect until a manual
|
|
core restart."""
|
|
|
|
def _fake_recreate(_ids):
|
|
return False, "docker compose exploded"
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_recreate", _fake_recreate)
|
|
# Must not raise
|
|
_post_install_core_recreate("openclaw")
|
|
|
|
|
|
class TestRunInstallCallsPostInstallRecreate:
|
|
"""Source-level check that the install closure calls
|
|
_post_install_core_recreate after the "started" progress write.
|
|
|
|
The dynamic flow runs in a daemon thread + nested closure, which makes
|
|
runtime mocking fragile (see TestInstallHookEnvAllowlist for the same
|
|
reasoning). Source-level assertion is sufficient to lock the wiring."""
|
|
|
|
def _install_source(self):
|
|
import inspect
|
|
return inspect.getsource(_mod.AgentHandler._handle_install)
|
|
|
|
def test_install_calls_post_install_core_recreate(self):
|
|
src = self._install_source()
|
|
assert "_post_install_core_recreate(service_id)" in src, (
|
|
"_run_install must invoke _post_install_core_recreate(service_id) "
|
|
"after emitting the 'started' progress record"
|
|
)
|
|
|
|
def test_recreate_is_after_started_progress_write(self):
|
|
src = self._install_source()
|
|
started_idx = src.find('"started"')
|
|
recreate_idx = src.find("_post_install_core_recreate(")
|
|
assert started_idx != -1, "expected 'started' progress write in _handle_install"
|
|
assert recreate_idx != -1, "expected _post_install_core_recreate call in _handle_install"
|
|
assert started_idx < recreate_idx, (
|
|
"_post_install_core_recreate must run AFTER the 'started' progress "
|
|
"write so the client sees success even if the recreate fails"
|
|
)
|
|
|
|
|
|
# --- _handle_env_update ---
|
|
|
|
|
|
class _FakeHandler:
|
|
"""Minimal stand-in for BaseHTTPRequestHandler used by _handle_env_update."""
|
|
|
|
def __init__(self, body: bytes, headers=None):
|
|
merged = {
|
|
"Authorization": "Bearer test-key",
|
|
"Content-Length": str(len(body)),
|
|
}
|
|
if headers:
|
|
merged.update(headers)
|
|
self.headers = merged
|
|
self.rfile = io.BytesIO(body)
|
|
self.wfile = io.BytesIO()
|
|
self.client_address = ("127.0.0.1", 12345)
|
|
self.response_code = None
|
|
self.response_headers = []
|
|
|
|
def send_response(self, code):
|
|
self.response_code = code
|
|
|
|
def send_header(self, name, value):
|
|
self.response_headers.append((name, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def parse_response(self):
|
|
# json_response writes the JSON body via wfile.write()
|
|
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
|
|
|
|
|
class TestTailscaleStatus:
|
|
"""Direct host-agent tests for /v1/tailscale/status behavior."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _auth(self, monkeypatch):
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
|
|
def _handler(self):
|
|
handler = _FakeHandler(b"")
|
|
handler._write_tailscale_status_payload = types.MethodType(
|
|
_mod.AgentHandler._write_tailscale_status_payload, handler
|
|
)
|
|
handler._find_native_tailscale_cli = types.MethodType(
|
|
_mod.AgentHandler._find_native_tailscale_cli, handler
|
|
)
|
|
handler._try_native_tailscale_status = types.MethodType(
|
|
_mod.AgentHandler._try_native_tailscale_status, handler
|
|
)
|
|
return handler
|
|
|
|
def test_falls_back_to_native_tailscale_when_container_absent(self, monkeypatch):
|
|
payload = {
|
|
"BackendState": "Running",
|
|
"Self": {
|
|
"HostName": "ods-win",
|
|
"DNSName": "ods-win.tail-example.ts.net.",
|
|
"TailscaleIPs": ["100.64.0.42"],
|
|
"Online": True,
|
|
},
|
|
"MagicDNSSuffix": "tail-example.ts.net",
|
|
"CurrentTailnet": {"Name": "example.com"},
|
|
}
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
if cmd[:2] == ["docker", "exec"]:
|
|
return subprocess.CompletedProcess(cmd, 1, "", "No such container: ods-tailscale")
|
|
if cmd[:3] == ["tailscale", "status", "--json"]:
|
|
return subprocess.CompletedProcess(cmd, 0, json.dumps(payload), "")
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr(_mod.shutil, "which", lambda name: "tailscale" if name == "tailscale" else None)
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = self._handler()
|
|
_mod.AgentHandler._handle_tailscale_status(handler)
|
|
body = handler.parse_response()
|
|
|
|
assert handler.response_code == 200
|
|
assert body["running"] is True
|
|
assert body["authenticated"] is True
|
|
assert body["source"] == "native"
|
|
assert body["self"]["dns_name"] == "ods-win.tail-example.ts.net"
|
|
|
|
def test_absent_container_without_native_tailscale_is_not_running(self, monkeypatch):
|
|
def fake_run(cmd, *args, **kwargs):
|
|
return subprocess.CompletedProcess(cmd, 1, "", "No such container: ods-tailscale")
|
|
|
|
monkeypatch.setattr(_mod.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = self._handler()
|
|
_mod.AgentHandler._handle_tailscale_status(handler)
|
|
body = handler.parse_response()
|
|
|
|
assert handler.response_code == 200
|
|
assert body == {"running": False}
|
|
|
|
|
|
class TestServiceRestart:
|
|
"""Direct host-agent tests for POST /v1/service/restart behavior."""
|
|
|
|
def _write_manifest(self, ext_root: Path, service_id: str, service_block: str):
|
|
pytest.importorskip("yaml")
|
|
ext_dir = ext_root / service_id
|
|
ext_dir.mkdir(parents=True, exist_ok=True)
|
|
(ext_dir / "manifest.yaml").write_text(
|
|
"schema_version: ods.services.v1\n"
|
|
"service:\n"
|
|
f" id: {service_id}\n"
|
|
f"{service_block}",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _configure(self, tmp_path, monkeypatch):
|
|
builtin_root = tmp_path / "builtin"
|
|
user_root = tmp_path / "user"
|
|
builtin_root.mkdir()
|
|
user_root.mkdir()
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "CORE_SERVICE_IDS", set())
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
return builtin_root, user_root
|
|
|
|
def _body(self, service_id: str, **extra) -> bytes:
|
|
return json.dumps({"service_id": service_id, **extra}).encode("utf-8")
|
|
|
|
def test_valid_restart_calls_docker_restart(self, tmp_path, monkeypatch):
|
|
builtin_root, _ = self._configure(tmp_path, monkeypatch)
|
|
self._write_manifest(
|
|
builtin_root,
|
|
"ape",
|
|
" name: APE\n"
|
|
" container_name: ods-ape\n",
|
|
)
|
|
_mod._service_locks.pop("ape", None)
|
|
monkeypatch.setattr(_mod, "_resolve_container_name", lambda sid: "ods-ape")
|
|
|
|
calls = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
calls.append(cmd)
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = _FakeHandler(self._body("ape"))
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["action"] == "restart"
|
|
assert calls == [["docker", "restart", "ods-ape"]]
|
|
|
|
def test_restart_requires_auth(self, tmp_path, monkeypatch):
|
|
self._configure(tmp_path, monkeypatch)
|
|
handler = _FakeHandler(self._body("ape"), headers={"Authorization": "Bearer wrong"})
|
|
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
|
|
assert handler.response_code == 403
|
|
|
|
def test_invalid_service_id_rejected(self, tmp_path, monkeypatch):
|
|
self._configure(tmp_path, monkeypatch)
|
|
handler = _FakeHandler(self._body("../ape"))
|
|
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
|
|
assert handler.response_code == 400
|
|
assert handler.parse_response()["error"] == "Invalid service_id"
|
|
|
|
def test_unknown_service_rejected(self, tmp_path, monkeypatch):
|
|
self._configure(tmp_path, monkeypatch)
|
|
handler = _FakeHandler(self._body("not-installed"))
|
|
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
|
|
assert handler.response_code == 404
|
|
assert "not-installed" in handler.parse_response()["error"]
|
|
|
|
def test_host_systemd_service_rejected_before_docker(self, tmp_path, monkeypatch):
|
|
builtin_root, _ = self._configure(tmp_path, monkeypatch)
|
|
self._write_manifest(
|
|
builtin_root,
|
|
"opencode",
|
|
" name: OpenCode\n"
|
|
" type: host-systemd\n"
|
|
" container_name: \"\"\n",
|
|
)
|
|
|
|
def fake_run(*args, **kwargs):
|
|
pytest.fail("host-systemd service must not run docker restart")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = _FakeHandler(self._body("opencode"))
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
|
|
assert handler.response_code == 400
|
|
assert "host-level" in handler.parse_response()["error"].lower()
|
|
|
|
def test_extension_without_manifest_is_not_restartable(self, tmp_path, monkeypatch):
|
|
builtin_root, _ = self._configure(tmp_path, monkeypatch)
|
|
(builtin_root / "broken").mkdir()
|
|
|
|
def fake_run(*args, **kwargs):
|
|
pytest.fail("missing manifest service must not run docker restart")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = _FakeHandler(self._body("broken"))
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
|
|
assert handler.response_code == 400
|
|
assert "manifest" in handler.parse_response()["error"].lower()
|
|
|
|
def test_concurrent_restart_returns_409(self, tmp_path, monkeypatch):
|
|
builtin_root, _ = self._configure(tmp_path, monkeypatch)
|
|
self._write_manifest(
|
|
builtin_root,
|
|
"ape",
|
|
" name: APE\n"
|
|
" container_name: ods-ape\n",
|
|
)
|
|
lock = _mod._service_locks["ape"]
|
|
assert lock.acquire(blocking=False) is True
|
|
try:
|
|
handler = _FakeHandler(self._body("ape"))
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
finally:
|
|
lock.release()
|
|
_mod._service_locks.pop("ape", None)
|
|
|
|
assert handler.response_code == 409
|
|
|
|
def test_delayed_restart_returns_accepted_and_releases_lock(self, tmp_path, monkeypatch):
|
|
builtin_root, _ = self._configure(tmp_path, monkeypatch)
|
|
self._write_manifest(
|
|
builtin_root,
|
|
"dashboard-api",
|
|
" name: Dashboard API\n"
|
|
" container_name: ods-dashboard-api\n",
|
|
)
|
|
_mod._service_locks.pop("dashboard-api", None)
|
|
monkeypatch.setattr(_mod, "_resolve_container_name", lambda sid: "ods-dashboard-api")
|
|
monkeypatch.setattr(_mod.time, "sleep", lambda *_args: None)
|
|
|
|
class ImmediateThread:
|
|
def __init__(self, target=None, daemon=None, **kwargs):
|
|
self._target = target
|
|
|
|
def start(self):
|
|
self._target()
|
|
|
|
monkeypatch.setattr(_mod.threading, "Thread", ImmediateThread)
|
|
|
|
calls = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
calls.append(cmd)
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = _FakeHandler(self._body("dashboard-api", delay_seconds=1))
|
|
_mod.AgentHandler._handle_service_restart(handler)
|
|
|
|
assert handler.response_code == 202
|
|
assert handler.parse_response()["status"] == "accepted"
|
|
assert calls == [["docker", "restart", "ods-dashboard-api"]]
|
|
assert _mod._service_locks["dashboard-api"].acquire(blocking=False) is True
|
|
_mod._service_locks["dashboard-api"].release()
|
|
|
|
|
|
@pytest.fixture
|
|
def env_update_env(tmp_path, monkeypatch):
|
|
"""Wire up INSTALL_DIR/DATA_DIR/AGENT_API_KEY for _handle_env_update tests."""
|
|
install_dir = tmp_path / "install"
|
|
install_dir.mkdir()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
|
|
schema = {
|
|
"properties": {
|
|
"ODS_AGENT_KEY": {"type": "string"},
|
|
"GGUF_FILE": {"type": "string"},
|
|
}
|
|
}
|
|
(install_dir / ".env.schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
|
(install_dir / ".env").write_text("ODS_AGENT_KEY=existing\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
return install_dir, data_dir
|
|
|
|
|
|
def _make_body(raw_text: str, backup: bool = True) -> bytes:
|
|
return json.dumps({"raw_text": raw_text, "backup": backup}).encode("utf-8")
|
|
|
|
|
|
class TestHandleEnvUpdate:
|
|
|
|
def test_happy_path_writes_file_and_returns_backup(self, env_update_env):
|
|
install_dir, data_dir = env_update_env
|
|
body = _make_body("ODS_AGENT_KEY=newvalue\nGGUF_FILE=/models/foo.gguf\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 200
|
|
resp = handler.parse_response()
|
|
assert resp["status"] == "ok"
|
|
assert resp["backup_path"].startswith("data/config-backups/.env.backup.")
|
|
env_text = (install_dir / ".env").read_text(encoding="utf-8")
|
|
assert "ODS_AGENT_KEY=newvalue" in env_text
|
|
assert "GGUF_FILE=/models/foo.gguf" in env_text
|
|
# backup file actually exists where the response says it does
|
|
backup_files = list((data_dir / "config-backups").glob(".env.backup.*"))
|
|
assert len(backup_files) == 1
|
|
|
|
def test_413_oversize_body(self, env_update_env):
|
|
# Construct headers claiming body is too large; rfile content is irrelevant.
|
|
handler = _FakeHandler(b"x", headers={"Content-Length": str(_mod.MAX_BODY + 999999) if hasattr(_mod, "MAX_BODY") else "100000"})
|
|
# MAX_ENV_BODY is hard-coded to 65536 inside the handler.
|
|
handler.headers["Content-Length"] = "70000"
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 413
|
|
assert "too large" in handler.parse_response()["error"].lower()
|
|
|
|
def test_accepts_unknown_key_with_warning(self, env_update_env):
|
|
"""Non-schema keys are accepted (warn, not reject) so extension-added
|
|
keys (e.g. JWT_SECRET from LibreChat) don't break Settings save."""
|
|
install_dir, data_dir = env_update_env
|
|
(install_dir / ".env").write_text("ODS_AGENT_KEY=old\n", encoding="utf-8")
|
|
body = _make_body("ODS_AGENT_KEY=old\nNOT_IN_SCHEMA=foo\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 200
|
|
env_text = (install_dir / ".env").read_text(encoding="utf-8")
|
|
assert "NOT_IN_SCHEMA=foo" in env_text
|
|
|
|
def test_400_malformed_line(self, env_update_env):
|
|
body = _make_body("THIS_LINE_HAS_NO_EQUALS\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 400
|
|
assert "Malformed line" in handler.parse_response()["error"]
|
|
|
|
def test_400_control_char_in_value(self, env_update_env):
|
|
body = _make_body("ODS_AGENT_KEY=foo\x00bar\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 400
|
|
assert "control characters" in handler.parse_response()["error"]
|
|
|
|
def test_400_control_char_escape_sequence(self, env_update_env):
|
|
# ESC (0x1b) — common in injected ANSI sequences
|
|
body = _make_body("ODS_AGENT_KEY=foo\x1b[31mbar\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 400
|
|
|
|
def test_tab_in_value_is_allowed(self, env_update_env):
|
|
# Tab is the only sub-32 char that should pass through.
|
|
body = _make_body("ODS_AGENT_KEY=foo\tbar\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 200
|
|
|
|
def test_409_lock_contention(self, env_update_env):
|
|
body = _make_body("ODS_AGENT_KEY=newvalue\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
assert _mod._model_activate_lock.acquire(blocking=False)
|
|
try:
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
finally:
|
|
_mod._model_activate_lock.release()
|
|
|
|
assert handler.response_code == 409
|
|
assert "in progress" in handler.parse_response()["error"]
|
|
|
|
def test_500_missing_schema(self, env_update_env):
|
|
install_dir, _ = env_update_env
|
|
(install_dir / ".env.schema.json").unlink()
|
|
body = _make_body("ODS_AGENT_KEY=newvalue\n")
|
|
handler = _FakeHandler(body)
|
|
|
|
_mod.AgentHandler._handle_env_update(handler)
|
|
|
|
assert handler.response_code == 500
|
|
assert ".env.schema.json not found" in handler.parse_response()["error"]
|
|
|
|
|
|
class TestHandleModelDownloadCancel:
|
|
|
|
def test_returns_no_download_when_idle(self, monkeypatch):
|
|
handler = _FakeHandler(b"")
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
monkeypatch.setattr(_mod, "_model_download_thread", None)
|
|
_mod._model_download_cancel.clear()
|
|
|
|
_mod.AgentHandler._handle_model_download_cancel(handler)
|
|
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["status"] == "no_download"
|
|
assert _mod._model_download_cancel.is_set() is False
|
|
|
|
def test_sets_cancel_flag_and_kills_active_proc(self, monkeypatch):
|
|
class _AliveThread:
|
|
def is_alive(self):
|
|
return True
|
|
|
|
class _FakeProc:
|
|
def __init__(self):
|
|
self.killed = False
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
handler = _FakeHandler(b"")
|
|
proc = _FakeProc()
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
monkeypatch.setattr(_mod, "_model_download_thread", _AliveThread())
|
|
monkeypatch.setattr(_mod, "_model_download_proc", proc)
|
|
_mod._model_download_cancel.clear()
|
|
|
|
_mod.AgentHandler._handle_model_download_cancel(handler)
|
|
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["status"] == "cancelling"
|
|
assert _mod._model_download_cancel.is_set() is True
|
|
assert proc.killed is True
|
|
|
|
|
|
class TestNarrowInstallPullFlags:
|
|
"""Filter flags used by the install pull step.
|
|
|
|
Audit follow-up on PR #1057: narrowing must drop -f entries
|
|
pointing at OTHER extensions, but keep base/GPU overlay and the
|
|
target extension's own fragments.
|
|
"""
|
|
|
|
def _ext_dirs(self, tmp_path):
|
|
builtins = tmp_path / "extensions" / "services"
|
|
users = tmp_path / "user-extensions"
|
|
builtins.mkdir(parents=True)
|
|
users.mkdir(parents=True)
|
|
return builtins, users
|
|
|
|
def test_drops_other_extension_compose(self, tmp_path, monkeypatch):
|
|
builtins, users = self._ext_dirs(tmp_path)
|
|
target_dir = builtins / "perplexica"
|
|
other_dir = builtins / "searxng"
|
|
target_dir.mkdir()
|
|
other_dir.mkdir()
|
|
target_compose = target_dir / "compose.yaml"
|
|
other_compose = other_dir / "compose.yaml"
|
|
target_compose.write_text("services: {}\n")
|
|
other_compose.write_text("services: {}\n")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", tmp_path)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtins)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", users)
|
|
|
|
flags = [
|
|
"-f", str(tmp_path / "docker-compose.base.yml"),
|
|
"-f", str(tmp_path / "docker-compose.nvidia.yml"),
|
|
"-f", str(target_compose),
|
|
"-f", str(other_compose),
|
|
]
|
|
narrowed = _mod._narrow_install_pull_flags(flags, "perplexica")
|
|
|
|
assert "-f" in narrowed
|
|
assert str(other_compose) not in narrowed
|
|
assert str(target_compose) in narrowed
|
|
|
|
def test_keeps_base_and_gpu_overlay(self, tmp_path, monkeypatch):
|
|
builtins, users = self._ext_dirs(tmp_path)
|
|
target_dir = builtins / "perplexica"
|
|
target_dir.mkdir()
|
|
target_compose = target_dir / "compose.yaml"
|
|
target_compose.write_text("services: {}\n")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", tmp_path)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtins)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", users)
|
|
|
|
base = str(tmp_path / "docker-compose.base.yml")
|
|
gpu = str(tmp_path / "docker-compose.nvidia.yml")
|
|
flags = ["-f", base, "-f", gpu, "-f", str(target_compose)]
|
|
narrowed = _mod._narrow_install_pull_flags(flags, "perplexica")
|
|
|
|
assert base in narrowed
|
|
assert gpu in narrowed
|
|
assert str(target_compose) in narrowed
|
|
|
|
def test_user_extension_target_is_kept(self, tmp_path, monkeypatch):
|
|
builtins, users = self._ext_dirs(tmp_path)
|
|
target_dir = users / "my-ext"
|
|
other_dir = builtins / "searxng"
|
|
target_dir.mkdir()
|
|
other_dir.mkdir()
|
|
target_compose = target_dir / "compose.yaml"
|
|
other_compose = other_dir / "compose.yaml"
|
|
target_compose.write_text("services: {}\n")
|
|
other_compose.write_text("services: {}\n")
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", tmp_path)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtins)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", users)
|
|
|
|
flags = ["-f", str(target_compose), "-f", str(other_compose)]
|
|
narrowed = _mod._narrow_install_pull_flags(flags, "my-ext")
|
|
|
|
assert str(target_compose) in narrowed
|
|
assert str(other_compose) not in narrowed
|
|
|
|
|
|
class TestNarrowedComposeSetResolves:
|
|
"""Validate that the narrowed compose set parses and contains the
|
|
target service. Audit follow-up on PR #1057.
|
|
"""
|
|
|
|
def test_returns_false_when_config_exits_nonzero(self, monkeypatch):
|
|
recorded = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
recorded.append(cmd)
|
|
return _SubprocessResult(returncode=1, stdout="", stderr="depends on undefined service")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
ok = _mod._narrowed_compose_set_resolves(
|
|
["-f", "/tmp/base.yml"], "perplexica", "/tmp", 60,
|
|
)
|
|
assert ok is False
|
|
assert recorded[0][:2] == ["docker", "compose"]
|
|
assert "config" in recorded[0] and "--services" in recorded[0]
|
|
|
|
def test_returns_false_when_target_service_missing_from_output(self, monkeypatch):
|
|
def fake_run(cmd, **kwargs):
|
|
return _SubprocessResult(returncode=0, stdout="searxng\nllama-server\n", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
ok = _mod._narrowed_compose_set_resolves([], "perplexica", "/tmp", 60)
|
|
assert ok is False
|
|
|
|
def test_returns_true_when_target_service_listed(self, monkeypatch):
|
|
def fake_run(cmd, **kwargs):
|
|
return _SubprocessResult(returncode=0, stdout="perplexica\nsearxng\n", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
ok = _mod._narrowed_compose_set_resolves([], "perplexica", "/tmp", 60)
|
|
assert ok is True
|
|
|
|
def test_returns_false_on_subprocess_error(self, monkeypatch):
|
|
def fake_run(cmd, **kwargs):
|
|
raise OSError("docker not found")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
ok = _mod._narrowed_compose_set_resolves([], "perplexica", "/tmp", 60)
|
|
assert ok is False
|
|
|
|
|
|
class TestInstallPullFallsBackOnUnresolvedNarrow:
|
|
"""Source-level wire-up checks for the install pull fallback.
|
|
|
|
These tests assert only that `_handle_install` *references* the
|
|
helpers and contains the fallback assignment token; behavioural
|
|
correctness of the narrow filter and the validator is covered by
|
|
`TestNarrowInstallPullFlags` and `TestNarrowedComposeSetResolves`
|
|
above. The token-presence pattern matches the established
|
|
`TestInstallStartCommandNoDeps` convention in this file.
|
|
"""
|
|
|
|
def test_install_references_narrow_helpers(self):
|
|
import inspect
|
|
src = inspect.getsource(_mod.AgentHandler._handle_install)
|
|
assert "_narrowed_compose_set_resolves" in src, (
|
|
"_handle_install source must reference _narrowed_compose_set_resolves"
|
|
)
|
|
assert "_narrow_install_pull_flags" in src, (
|
|
"_handle_install source must reference _narrow_install_pull_flags"
|
|
)
|
|
|
|
def test_install_source_contains_full_flags_fallback_token(self):
|
|
import inspect
|
|
src = inspect.getsource(_mod.AgentHandler._handle_install)
|
|
# Token-only check: confirms a `pull_flags = flags` assignment
|
|
# exists somewhere in the handler. Does not verify control flow.
|
|
assert "pull_flags = flags" in src, (
|
|
"_handle_install source must contain a `pull_flags = flags` "
|
|
"assignment (the fallback token)"
|
|
)
|
|
|
|
|
|
class _SubprocessResult:
|
|
"""Minimal stand-in for subprocess.CompletedProcess."""
|
|
|
|
def __init__(self, returncode: int, stdout: str, stderr: str):
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
# --- _precreate_data_dirs + install flow (PR 2A regressions) ---
|
|
#
|
|
# Defect 1/2: _run_install and _precreate_data_dirs must use _find_ext_dir()
|
|
# so built-in extensions (under EXTENSIONS_DIR) are found — the old
|
|
# USER_EXTENSIONS_DIR-only path silently no-op'd for every built-in.
|
|
#
|
|
# Defect 3: _precreate_data_dirs must create dirs for any relative bind
|
|
# source (not just "./data/..."), so extensions with "./upload:/..." style
|
|
# mounts also get their dirs pre-created. Anchored on INSTALL_DIR because
|
|
# Docker Compose v2 resolves relative bind paths against the project
|
|
# directory (the first -f file's parent = INSTALL_DIR), not against the
|
|
# individual fragment's directory.
|
|
#
|
|
# Defect 5: _handle_install must verify the container reached "running"
|
|
# state before reporting success — compose `up -d` returns 0 even for
|
|
# Created/Exited/Restarting containers.
|
|
|
|
|
|
class TestPrecreateDataDirs:
|
|
|
|
def _write_compose(self, ext_dir: Path, volumes: list[str]):
|
|
vol_yaml = "\n".join(f" - {v}" for v in volumes)
|
|
ext_dir.mkdir(parents=True, exist_ok=True)
|
|
(ext_dir / "compose.yaml").write_text(
|
|
"services:\n"
|
|
" svc:\n"
|
|
" image: test:latest\n"
|
|
" volumes:\n" + vol_yaml + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _write_compose_with_user(
|
|
self, ext_dir: Path, volumes: list[str], user: str,
|
|
):
|
|
vol_yaml = "\n".join(f" - {v}" for v in volumes)
|
|
ext_dir.mkdir(parents=True, exist_ok=True)
|
|
(ext_dir / "compose.yaml").write_text(
|
|
"services:\n"
|
|
" svc:\n"
|
|
" image: test:latest\n"
|
|
f" user: \"{user}\"\n"
|
|
" volumes:\n" + vol_yaml + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _write_manifest(self, ext_dir: Path, service_id: str, container_uid: int):
|
|
(ext_dir / "manifest.yaml").write_text(
|
|
"schema_version: ods.services.v1\n"
|
|
"service:\n"
|
|
f" id: {service_id}\n"
|
|
f" name: {service_id}\n"
|
|
f" container_uid: {container_uid}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def test_creates_dirs_for_builtin_ext_via_find_ext_dir(self, tmp_path, monkeypatch):
|
|
"""Defect 1/2: built-in extensions resolved via _find_ext_dir, not USER_EXTENSIONS_DIR."""
|
|
pytest.importorskip("yaml")
|
|
builtin_root = tmp_path / "builtin"
|
|
user_root = tmp_path / "user"
|
|
install_dir = tmp_path / "install"
|
|
builtin_root.mkdir()
|
|
user_root.mkdir()
|
|
install_dir.mkdir()
|
|
ext_dir = builtin_root / "svc-b"
|
|
self._write_compose(ext_dir, ["./data/state:/state"])
|
|
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
|
|
_mod._precreate_data_dirs("svc-b")
|
|
|
|
# Dir lives under INSTALL_DIR (the Compose project directory),
|
|
# NOT under ext_dir — matching where Compose actually mounts.
|
|
assert (install_dir / "data" / "state").is_dir()
|
|
assert not (ext_dir / "data" / "state").exists()
|
|
|
|
def test_creates_dirs_for_non_data_prefix(self, tmp_path, monkeypatch):
|
|
"""Defect 3: relative bind sources outside './data/' must still be created."""
|
|
pytest.importorskip("yaml")
|
|
user_root = tmp_path / "user"
|
|
builtin_root = tmp_path / "builtin"
|
|
install_dir = tmp_path / "install"
|
|
user_root.mkdir()
|
|
builtin_root.mkdir()
|
|
install_dir.mkdir()
|
|
ext_dir = user_root / "svc-u"
|
|
self._write_compose(ext_dir, ["./upload:/upload", "./data/state:/state"])
|
|
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
|
|
_mod._precreate_data_dirs("svc-u")
|
|
|
|
# Both non-"./data/" and "./data/..." mounts must materialise under
|
|
# INSTALL_DIR (the Compose project directory).
|
|
assert (install_dir / "upload").is_dir()
|
|
assert (install_dir / "data" / "state").is_dir()
|
|
|
|
def test_manifest_container_uid_fallback_chowns_when_root(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""Manifest container_uid covers images that set USER in Dockerfile."""
|
|
pytest.importorskip("yaml")
|
|
user_root = tmp_path / "user"
|
|
builtin_root = tmp_path / "builtin"
|
|
install_dir = tmp_path / "install"
|
|
user_root.mkdir()
|
|
builtin_root.mkdir()
|
|
install_dir.mkdir()
|
|
ext_dir = user_root / "svc-u"
|
|
self._write_compose(ext_dir, ["./data/gaia:/home/gaia/.gaia"])
|
|
self._write_manifest(ext_dir, "svc-u", 10001)
|
|
chowns = []
|
|
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "_fs_type", lambda path: None)
|
|
monkeypatch.setattr(_mod.os, "getuid", lambda: 0, raising=False)
|
|
monkeypatch.setattr(
|
|
_mod.os,
|
|
"chown",
|
|
lambda path, uid, gid: chowns.append((Path(path), uid, gid)),
|
|
raising=False,
|
|
)
|
|
|
|
_mod._precreate_data_dirs("svc-u")
|
|
|
|
data_dir = install_dir / "data" / "gaia"
|
|
assert data_dir.is_dir()
|
|
assert chowns == [(data_dir, 10001, 10001)]
|
|
|
|
def test_compose_user_takes_precedence_over_manifest_uid(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""Explicit compose user remains the source of truth when present."""
|
|
pytest.importorskip("yaml")
|
|
user_root = tmp_path / "user"
|
|
builtin_root = tmp_path / "builtin"
|
|
install_dir = tmp_path / "install"
|
|
user_root.mkdir()
|
|
builtin_root.mkdir()
|
|
install_dir.mkdir()
|
|
ext_dir = user_root / "svc-u"
|
|
self._write_compose_with_user(
|
|
ext_dir,
|
|
["./data/gaia:/home/gaia/.gaia"],
|
|
"12345:12345",
|
|
)
|
|
self._write_manifest(ext_dir, "svc-u", 10001)
|
|
chowns = []
|
|
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "_fs_type", lambda path: None)
|
|
monkeypatch.setattr(_mod.os, "getuid", lambda: 0, raising=False)
|
|
monkeypatch.setattr(
|
|
_mod.os,
|
|
"chown",
|
|
lambda path, uid, gid: chowns.append((Path(path), uid, gid)),
|
|
raising=False,
|
|
)
|
|
|
|
_mod._precreate_data_dirs("svc-u")
|
|
|
|
data_dir = install_dir / "data" / "gaia"
|
|
assert data_dir.is_dir()
|
|
assert chowns == [(data_dir, 12345, 12345)]
|
|
|
|
def test_skips_named_volumes(self, tmp_path, monkeypatch):
|
|
"""Named volumes (no '/') must not trigger filesystem creation."""
|
|
pytest.importorskip("yaml")
|
|
user_root = tmp_path / "user"
|
|
builtin_root = tmp_path / "builtin"
|
|
install_dir = tmp_path / "install"
|
|
user_root.mkdir()
|
|
builtin_root.mkdir()
|
|
install_dir.mkdir()
|
|
ext_dir = user_root / "svc-n"
|
|
self._write_compose(ext_dir, ["named_vol:/var/lib/data"])
|
|
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
|
|
_mod._precreate_data_dirs("svc-n")
|
|
|
|
# Named volume must not materialize as a directory anywhere we own.
|
|
assert not (ext_dir / "named_vol").exists()
|
|
assert not (install_dir / "named_vol").exists()
|
|
|
|
|
|
class TestInstallRunningStateVerification:
|
|
"""Defect 5: `_handle_install` must poll container state before reporting success."""
|
|
|
|
def _install_source(self):
|
|
import inspect
|
|
return inspect.getsource(_mod.AgentHandler._handle_install)
|
|
|
|
def test_install_uses_find_ext_dir(self):
|
|
"""Defect 1: _run_install resolves ext_dir via _find_ext_dir, not USER_EXTENSIONS_DIR."""
|
|
src = self._install_source()
|
|
assert "_find_ext_dir(service_id)" in src
|
|
assert "USER_EXTENSIONS_DIR / service_id" not in src
|
|
|
|
def test_install_polls_docker_inspect_state(self):
|
|
"""State-poll loop must run `docker inspect` and check running state."""
|
|
src = self._install_source()
|
|
assert "docker" in src and "inspect" in src
|
|
assert "{{.State.Status}}" in src
|
|
assert 'state == "running"' in src
|
|
|
|
def test_install_writes_error_when_state_not_running(self):
|
|
"""Failed state-poll must surface as progress error, not 'started'.
|
|
|
|
The error message is built via f-string with the
|
|
manifest-driven `startup_timeout` rather than the literal "15s",
|
|
so we assert the constant prefix and the timeout reference, not
|
|
a hardcoded duration.
|
|
"""
|
|
src = self._install_source()
|
|
# Error path uses the existing _write_progress("error", ...) API.
|
|
assert '_write_progress(service_id, "error"' in src
|
|
# Error message template carries the dynamic startup_timeout.
|
|
assert "did not reach running state within" in src
|
|
assert "{startup_timeout}s" in src
|
|
|
|
def test_install_supports_startup_check_opt_out(self):
|
|
"""One-shot / setup-only extensions can set
|
|
`service.startup_check: false` in their manifest to skip the
|
|
running-state poll. The install completes after `compose up -d`
|
|
returns 0; the inspect loop is gated on `if startup_check:`.
|
|
"""
|
|
src = self._install_source()
|
|
# Manifest field is read with True default for back-compat.
|
|
assert 'startup_check = install_service_def.get("startup_check", True)' in src
|
|
# The state-poll loop is conditionally entered.
|
|
assert "if startup_check:" in src
|
|
|
|
|
|
# --- Enable-retry (PR 3A regression) ---
|
|
#
|
|
# When /v1/extension/start is called against a service whose extension-progress
|
|
# file shows status=error (prior failed install), the host agent must:
|
|
# * re-run the post_install hook if declared (env vars populated by the hook
|
|
# may be missing from the previous failure),
|
|
# * write progress transitions (starting → setup_hook → started/error) so the
|
|
# dashboard UI updates instead of displaying the stale error, and
|
|
# * fall back to the existing synchronous compose path for any service that
|
|
# isn't in an error state.
|
|
#
|
|
# Pre-fix, _handle_extension hit docker_compose_action directly without writing
|
|
# progress or re-running the hook, leaving the UI permanently stuck.
|
|
|
|
|
|
class _ImmediateThread:
|
|
"""Run thread targets synchronously so tests can assert on results."""
|
|
def __init__(self, target=None, daemon=None, **kwargs):
|
|
self._target = target
|
|
|
|
def start(self):
|
|
self._target()
|
|
|
|
|
|
class TestEnableRetry:
|
|
|
|
def _write_manifest(self, ext_dir: Path, with_hook: bool = True,
|
|
service_lines: str = ""):
|
|
ext_dir.mkdir(parents=True, exist_ok=True)
|
|
service_block = "service:\n port: 1234\n"
|
|
if service_lines:
|
|
service_block += service_lines
|
|
if with_hook:
|
|
hook = ext_dir / "setup.sh"
|
|
hook.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
|
|
hook.chmod(0o755)
|
|
(ext_dir / "manifest.yaml").write_text(
|
|
service_block +
|
|
" hooks:\n"
|
|
" post_install: setup.sh\n",
|
|
encoding="utf-8",
|
|
)
|
|
else:
|
|
(ext_dir / "manifest.yaml").write_text(
|
|
service_block,
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _write_progress_file(self, data_dir: Path, service_id: str, status: str):
|
|
progress_dir = data_dir / "extension-progress"
|
|
progress_dir.mkdir(parents=True, exist_ok=True)
|
|
(progress_dir / f"{service_id}.json").write_text(
|
|
json.dumps({"service_id": service_id, "status": status}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def _progress(self, data_dir: Path, service_id: str):
|
|
pf = data_dir / "extension-progress" / f"{service_id}.json"
|
|
if not pf.exists():
|
|
return None
|
|
return json.loads(pf.read_text(encoding="utf-8"))
|
|
|
|
def _body(self, service_id: str) -> bytes:
|
|
return json.dumps({"service_id": service_id}).encode("utf-8")
|
|
|
|
@pytest.fixture
|
|
def retry_env(self, tmp_path, monkeypatch):
|
|
pytest.importorskip("yaml")
|
|
install_dir = tmp_path / "install"
|
|
install_dir.mkdir()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
builtin_root = tmp_path / "builtin"
|
|
user_root = tmp_path / "user"
|
|
builtin_root.mkdir()
|
|
user_root.mkdir()
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
monkeypatch.setattr(_mod, "_usable_bash", "bash")
|
|
|
|
# Drop the per-service lock so retries across tests don't deadlock.
|
|
_mod._service_locks.pop("fakesvc", None)
|
|
|
|
# Force threading.Thread to run targets synchronously so the assertions
|
|
# below execute after the retry worker completes.
|
|
monkeypatch.setattr(_mod.threading, "Thread", _ImmediateThread)
|
|
|
|
return install_dir, data_dir, builtin_root, user_root
|
|
|
|
def test_retry_after_error_runs_hook_and_writes_started(self, retry_env, monkeypatch):
|
|
_, data_dir, builtin_root, _ = retry_env
|
|
ext_dir = builtin_root / "fakesvc"
|
|
self._write_manifest(ext_dir, with_hook=True)
|
|
self._write_progress_file(data_dir, "fakesvc", "error")
|
|
|
|
hook_cmds = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
if cmd[:3] == ["docker", "inspect", "--format"]:
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0,
|
|
stdout="running|", stderr="")
|
|
hook_cmds.append(cmd)
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0,
|
|
stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
compose_calls = []
|
|
|
|
def fake_compose(sid, action):
|
|
compose_calls.append((sid, action))
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_action", fake_compose)
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
assert handler.response_code == 202
|
|
assert handler.parse_response()["status"] == "retrying"
|
|
# post_install hook was invoked via bash against setup.sh
|
|
assert any(
|
|
len(c) >= 2 and c[0] == _mod._usable_bash and c[1].endswith("setup.sh")
|
|
for c in hook_cmds
|
|
), f"expected setup.sh bash invocation, saw {hook_cmds}"
|
|
# docker compose start was called after the hook
|
|
assert ("fakesvc", "start") in compose_calls
|
|
# Progress landed on 'started'
|
|
progress = self._progress(data_dir, "fakesvc")
|
|
assert progress is not None
|
|
assert progress["status"] == "started"
|
|
|
|
def test_retry_startup_check_failure_writes_error(self, retry_env, monkeypatch):
|
|
_, data_dir, builtin_root, _ = retry_env
|
|
ext_dir = builtin_root / "fakesvc"
|
|
self._write_manifest(
|
|
ext_dir, with_hook=False,
|
|
service_lines=" startup_timeout: 1\n",
|
|
)
|
|
self._write_progress_file(data_dir, "fakesvc", "error")
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_action",
|
|
lambda sid, act: (True, ""))
|
|
ticks = iter([0, 0, 2])
|
|
monkeypatch.setattr(_mod.time, "monotonic", lambda: next(ticks, 2))
|
|
monkeypatch.setattr(_mod.time, "sleep", lambda *_args: None)
|
|
|
|
inspect_calls = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
inspect_calls.append(cmd)
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0,
|
|
stdout="exited|boom", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
assert handler.response_code == 202
|
|
assert any(cmd[:3] == ["docker", "inspect", "--format"]
|
|
for cmd in inspect_calls)
|
|
progress = self._progress(data_dir, "fakesvc")
|
|
assert progress is not None
|
|
assert progress["status"] == "error"
|
|
assert "state=exited" in (progress["error"] or "")
|
|
assert "boom" in (progress["error"] or "")
|
|
|
|
def test_retry_startup_check_opt_out_writes_started(self, retry_env, monkeypatch):
|
|
_, data_dir, builtin_root, _ = retry_env
|
|
ext_dir = builtin_root / "fakesvc"
|
|
self._write_manifest(
|
|
ext_dir, with_hook=False,
|
|
service_lines=" startup_check: false\n",
|
|
)
|
|
self._write_progress_file(data_dir, "fakesvc", "error")
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_action",
|
|
lambda sid, act: (True, ""))
|
|
|
|
def fake_run(*args, **kwargs):
|
|
pytest.fail("startup_check false should not inspect container state")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
assert handler.response_code == 202
|
|
progress = self._progress(data_dir, "fakesvc")
|
|
assert progress is not None
|
|
assert progress["status"] == "started"
|
|
|
|
def test_retry_hook_failure_writes_error_and_skips_compose(self, retry_env, monkeypatch):
|
|
_, data_dir, builtin_root, _ = retry_env
|
|
ext_dir = builtin_root / "fakesvc"
|
|
self._write_manifest(ext_dir, with_hook=True)
|
|
self._write_progress_file(data_dir, "fakesvc", "error")
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
return subprocess.CompletedProcess(args=cmd, returncode=1,
|
|
stdout="", stderr="hook boom")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
|
|
compose_calls = []
|
|
monkeypatch.setattr(
|
|
_mod, "docker_compose_action",
|
|
lambda sid, act: (compose_calls.append((sid, act)) or (True, "")),
|
|
)
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
assert handler.response_code == 202
|
|
progress = self._progress(data_dir, "fakesvc")
|
|
assert progress is not None
|
|
assert progress["status"] == "error"
|
|
assert "hook boom" in (progress["error"] or "")
|
|
# Hook failure must NOT proceed to compose start
|
|
assert compose_calls == []
|
|
|
|
def test_no_progress_file_uses_sync_path_without_progress_write(self, retry_env, monkeypatch):
|
|
_, data_dir, builtin_root, _ = retry_env
|
|
ext_dir = builtin_root / "fakesvc"
|
|
self._write_manifest(ext_dir, with_hook=True)
|
|
# Deliberately no progress file.
|
|
|
|
hook_cmds = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
hook_cmds.append(cmd)
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0,
|
|
stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(_mod, "docker_compose_action",
|
|
lambda sid, act: (True, ""))
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
# Synchronous success → 200, not 202
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["status"] == "ok"
|
|
# Sync path must not re-run the hook
|
|
assert not any(
|
|
len(c) >= 2 and c[0] == "bash" and c[1].endswith("setup.sh")
|
|
for c in hook_cmds
|
|
)
|
|
# Sync path must not write a progress file
|
|
assert self._progress(data_dir, "fakesvc") is None
|
|
|
|
def test_progress_status_started_uses_sync_path(self, retry_env, monkeypatch):
|
|
_, data_dir, builtin_root, _ = retry_env
|
|
ext_dir = builtin_root / "fakesvc"
|
|
self._write_manifest(ext_dir, with_hook=True)
|
|
self._write_progress_file(data_dir, "fakesvc", "started")
|
|
|
|
hook_cmds = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
hook_cmds.append(cmd)
|
|
return subprocess.CompletedProcess(args=cmd, returncode=0,
|
|
stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(_mod, "docker_compose_action",
|
|
lambda sid, act: (True, ""))
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
assert handler.response_code == 200
|
|
# Sync path must not re-run the hook
|
|
assert not any(
|
|
len(c) >= 2 and c[0] == "bash" and c[1].endswith("setup.sh")
|
|
for c in hook_cmds
|
|
)
|
|
# Progress must be unchanged
|
|
assert self._progress(data_dir, "fakesvc")["status"] == "started"
|
|
|
|
|
|
class TestInstallStatePollBehavior:
|
|
"""End-to-end behavioral tests for the running-state poll inside
|
|
``AgentHandler._handle_install``.
|
|
|
|
The existing :class:`TestInstallRunningStateVerification` class above is
|
|
100% source-inspection (asserts substrings appear in the function body).
|
|
This class drives the real handler over HTTP with a mocked
|
|
``subprocess.run`` so that a refactor that preserves the substrings but
|
|
breaks the runtime behavior would still get caught.
|
|
|
|
Pattern matches :class:`TestComposeCacheInvalidationWire` and
|
|
:class:`TestComposeToggleWire` above:
|
|
* Spin up an in-process ``HTTPServer`` bound to ``AgentHandler``.
|
|
* POST to ``/v1/extension/install`` with the bearer token.
|
|
* Wait for the install thread to write a terminal status to the
|
|
progress file (``status in {'started', 'error'}``).
|
|
* Assert on the progress payload + on the recorded subprocess calls.
|
|
|
|
Time is virtualised — ``time.sleep`` and ``time.monotonic`` are
|
|
monkeypatched on the host-agent module so a 15-second deadline elapses
|
|
instantly. Tests must not actually wait wall-clock seconds.
|
|
"""
|
|
|
|
PROGRESS_WAIT_SECONDS = 5.0
|
|
|
|
def _make_extension(self, user_root, sid, *, startup_check=True,
|
|
startup_timeout=None, container_name=None):
|
|
"""Create a minimal user-extension dir with manifest only.
|
|
|
|
No ``compose.yaml`` is written — that keeps ``_precreate_data_dirs``
|
|
an early-return no-op so the only ``subprocess.run`` invocations are
|
|
the ones the install path itself issues (compose pull / compose up /
|
|
docker inspect).
|
|
"""
|
|
import yaml # PyYAML is a hard dep of dashboard-api; if missing the
|
|
# whole test module would already have failed at import.
|
|
ext_dir = user_root / sid
|
|
ext_dir.mkdir(parents=True)
|
|
service_def = {}
|
|
if startup_check is False:
|
|
service_def["startup_check"] = False
|
|
if startup_timeout is not None:
|
|
service_def["startup_timeout"] = startup_timeout
|
|
if container_name is not None:
|
|
service_def["container_name"] = container_name
|
|
manifest = {
|
|
"schema_version": "ods.services.v1",
|
|
"id": sid,
|
|
"service": service_def,
|
|
}
|
|
(ext_dir / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8")
|
|
return ext_dir
|
|
|
|
def _post_install(self, port, key, sid):
|
|
import urllib.request
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{port}/v1/extension/install",
|
|
data=json.dumps({"service_id": sid}).encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
assert resp.status == 202
|
|
return json.loads(resp.read())
|
|
|
|
def _wait_for_terminal(self, progress_file, timeout=None):
|
|
import time as _real_time
|
|
timeout = timeout if timeout is not None else self.PROGRESS_WAIT_SECONDS
|
|
deadline = _real_time.monotonic() + timeout
|
|
while _real_time.monotonic() < deadline:
|
|
if progress_file.exists():
|
|
try:
|
|
payload = json.loads(progress_file.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
payload = None
|
|
if payload and payload.get("status") in {"started", "error"}:
|
|
return payload
|
|
_real_time.sleep(0.05)
|
|
raise AssertionError(
|
|
f"Install thread did not reach a terminal status within {timeout}s; "
|
|
f"last seen: {progress_file.read_text(encoding='utf-8') if progress_file.exists() else '<missing>'}"
|
|
)
|
|
|
|
def _setup_agent(self, tmp_path, monkeypatch, *, sid, startup_check=True,
|
|
startup_timeout=None, container_name=None):
|
|
"""Common scaffolding: temp dirs, manifest, monkeypatched module
|
|
constants, virtual clock, returns ``(install_dir, progress_file, ext_dir)``."""
|
|
install_dir = tmp_path / "install"
|
|
data_dir = tmp_path / "data"
|
|
user_root = tmp_path / "user-extensions"
|
|
builtin_root = tmp_path / "builtin-empty"
|
|
install_dir.mkdir()
|
|
data_dir.mkdir()
|
|
user_root.mkdir()
|
|
builtin_root.mkdir()
|
|
# Pre-populate compose flags so resolve_compose_flags() doesn't
|
|
# shell out to resolve-compose-stack.sh (which doesn't exist here).
|
|
(install_dir / ".compose-flags").write_text(
|
|
"--env-file .env -f docker-compose.base.yml", encoding="utf-8",
|
|
)
|
|
|
|
ext_dir = self._make_extension(
|
|
user_root, sid,
|
|
startup_check=startup_check,
|
|
startup_timeout=startup_timeout,
|
|
container_name=container_name,
|
|
)
|
|
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "wire-test-secret")
|
|
|
|
# Virtual clock so the 15s startup_timeout deadline elapses
|
|
# instantly. ``time.sleep(1)`` advances the fake clock by 1.0 and
|
|
# returns immediately; ``time.monotonic()`` returns the current value.
|
|
# Replace ``_mod.time`` with a stub namespace rather than mutating
|
|
# attributes on the real ``time`` module — otherwise the test helper
|
|
# itself (which uses real ``time.sleep`` to wait for the install
|
|
# thread) ends up calling the no-op fake and busy-loops in zero
|
|
# wall-clock time, racing the install thread.
|
|
import time as _real_time
|
|
clock = [0.0]
|
|
def fake_monotonic():
|
|
return clock[0]
|
|
def fake_sleep(seconds):
|
|
clock[0] += float(seconds)
|
|
fake_time = types.SimpleNamespace(
|
|
monotonic=fake_monotonic,
|
|
sleep=fake_sleep,
|
|
time=_real_time.time,
|
|
)
|
|
monkeypatch.setattr(_mod, "time", fake_time)
|
|
|
|
progress_file = data_dir / "extension-progress" / f"{sid}.json"
|
|
return install_dir, progress_file, ext_dir
|
|
|
|
def _start_server(self, monkeypatch):
|
|
import threading
|
|
from http.server import HTTPServer
|
|
server = HTTPServer(("127.0.0.1", 0), _mod.AgentHandler)
|
|
port = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
return server, thread, port
|
|
|
|
@staticmethod
|
|
def _is_inspect_call(call):
|
|
"""Return True if ``call`` is the docker-inspect state probe."""
|
|
argv = call["argv"]
|
|
return (
|
|
len(argv) >= 2
|
|
and argv[0] == "docker"
|
|
and argv[1] == "inspect"
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_compose_call(call, verb):
|
|
"""Return True if ``call`` is a ``docker compose <verb> ...`` call."""
|
|
argv = call["argv"]
|
|
if len(argv) < 3 or argv[0] != "docker" or argv[1] != "compose":
|
|
return False
|
|
return verb in argv
|
|
|
|
def _install_subprocess_mock(self, monkeypatch, inspect_responses):
|
|
"""Install a ``subprocess.run`` patch on the host-agent module.
|
|
|
|
``inspect_responses`` is a list of items consumed in order for each
|
|
``docker inspect`` call. Each item is either:
|
|
* a tuple ``(state, error)`` -> return rc=0 with ``"<state>|<error>"``
|
|
* the exception class ``subprocess.TimeoutExpired`` (or an instance) ->
|
|
raise it for that call
|
|
* a callable ``(argv) -> CompletedProcess`` for full custom control
|
|
Compose ``pull`` and ``up`` always succeed (rc=0).
|
|
Returns a ``calls`` list (each entry: ``{'argv': [...], 'kwargs': {...}}``).
|
|
"""
|
|
calls = []
|
|
responses = list(inspect_responses)
|
|
|
|
class _CP: # minimal stand-in for subprocess.CompletedProcess
|
|
def __init__(self, returncode, stdout="", stderr=""):
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
|
|
def fake_run(argv, **kwargs):
|
|
calls.append({"argv": list(argv), "kwargs": dict(kwargs)})
|
|
|
|
# docker inspect ... -> consume next scripted response
|
|
if (len(argv) >= 2 and argv[0] == "docker" and argv[1] == "inspect"):
|
|
if not responses:
|
|
return _CP(0, "running|", "")
|
|
resp = responses.pop(0)
|
|
if isinstance(resp, type) and issubclass(resp, BaseException):
|
|
raise resp(cmd=argv, timeout=5)
|
|
if isinstance(resp, BaseException):
|
|
raise resp
|
|
if callable(resp):
|
|
return resp(argv)
|
|
state, err = resp
|
|
return _CP(0, f"{state}|{err}", "")
|
|
|
|
# docker compose ... -> always success.
|
|
if (len(argv) >= 2 and argv[0] == "docker" and argv[1] == "compose"):
|
|
return _CP(0, "", "")
|
|
|
|
# Anything else: refuse so the test fails loudly rather than
|
|
# silently shelling out.
|
|
raise AssertionError(f"unexpected subprocess.run argv: {argv}")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
return calls
|
|
|
|
# ------------------------------------------------------------------
|
|
# Test cases
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_install_writes_error_progress_when_state_never_running(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""Container stuck in ``created`` for the whole startup window must
|
|
surface as ``status=error`` with a ``did not reach running state``
|
|
message — not as a false ``started``."""
|
|
sid = "fakesvc"
|
|
install_dir, progress_file, _ = self._setup_agent(
|
|
tmp_path, monkeypatch, sid=sid, startup_timeout=3,
|
|
)
|
|
# All inspect calls report 'created'; deadline must elapse.
|
|
# 3s timeout / 1s fake sleep -> 3 inspect calls.
|
|
calls = self._install_subprocess_mock(
|
|
monkeypatch,
|
|
inspect_responses=[("created", "")] * 10,
|
|
)
|
|
|
|
server, thread, port = self._start_server(monkeypatch)
|
|
try:
|
|
self._post_install(port, "wire-test-secret", sid)
|
|
payload = self._wait_for_terminal(progress_file)
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
assert payload["status"] == "error", payload
|
|
assert "did not reach running state" in (payload.get("error") or "")
|
|
# At least one inspect call happened (otherwise the gate isn't running).
|
|
assert any(self._is_inspect_call(c) for c in calls), calls
|
|
|
|
def test_install_skips_state_poll_when_startup_check_false(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""``service.startup_check: false`` must skip the docker-inspect
|
|
poll entirely and report ``started`` as soon as ``compose up``
|
|
returns 0."""
|
|
sid = "fakesvc"
|
|
install_dir, progress_file, _ = self._setup_agent(
|
|
tmp_path, monkeypatch, sid=sid, startup_check=False,
|
|
)
|
|
calls = self._install_subprocess_mock(
|
|
monkeypatch,
|
|
# Empty list: any inspect call would still get a default
|
|
# "running|" response — but the test asserts none happened.
|
|
inspect_responses=[],
|
|
)
|
|
|
|
server, thread, port = self._start_server(monkeypatch)
|
|
try:
|
|
self._post_install(port, "wire-test-secret", sid)
|
|
payload = self._wait_for_terminal(progress_file)
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
assert payload["status"] == "started", payload
|
|
inspect_calls = [c for c in calls if self._is_inspect_call(c)]
|
|
assert inspect_calls == [], (
|
|
f"docker inspect must NOT be called when startup_check is false; "
|
|
f"saw: {inspect_calls}"
|
|
)
|
|
# Sanity: compose up did happen.
|
|
assert any(self._is_compose_call(c, "up") for c in calls), calls
|
|
|
|
def test_install_records_started_on_state_transition(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""First inspect returns ``starting``, second returns ``running`` —
|
|
the loop must break on the transition and report ``started``."""
|
|
sid = "fakesvc"
|
|
install_dir, progress_file, _ = self._setup_agent(
|
|
tmp_path, monkeypatch, sid=sid, startup_timeout=10,
|
|
)
|
|
calls = self._install_subprocess_mock(
|
|
monkeypatch,
|
|
inspect_responses=[("starting", ""), ("running", "")],
|
|
)
|
|
|
|
server, thread, port = self._start_server(monkeypatch)
|
|
try:
|
|
self._post_install(port, "wire-test-secret", sid)
|
|
payload = self._wait_for_terminal(progress_file)
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
assert payload["status"] == "started", payload
|
|
inspect_calls = [c for c in calls if self._is_inspect_call(c)]
|
|
# Exactly two inspect calls: starting -> running, then break.
|
|
# Allow >=2 to accept any future implementation that double-checks.
|
|
assert len(inspect_calls) >= 2, inspect_calls
|
|
|
|
def test_install_tolerates_docker_inspect_timeout(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""A single ``subprocess.TimeoutExpired`` from docker inspect must
|
|
be absorbed by the poll loop — the install thread must NOT abort
|
|
the whole install on a one-off probe failure."""
|
|
import subprocess as _real_subprocess
|
|
sid = "fakesvc"
|
|
install_dir, progress_file, _ = self._setup_agent(
|
|
tmp_path, monkeypatch, sid=sid, startup_timeout=10,
|
|
)
|
|
# First inspect call raises TimeoutExpired; second returns "running".
|
|
# If the install thread propagates the timeout up to the outer
|
|
# try/except, _write_progress would be called with status="error"
|
|
# and message "timed out (...)". The test asserts "started" instead.
|
|
calls = self._install_subprocess_mock(
|
|
monkeypatch,
|
|
inspect_responses=[
|
|
_real_subprocess.TimeoutExpired,
|
|
("running", ""),
|
|
],
|
|
)
|
|
|
|
server, thread, port = self._start_server(monkeypatch)
|
|
try:
|
|
self._post_install(port, "wire-test-secret", sid)
|
|
payload = self._wait_for_terminal(progress_file)
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
assert payload["status"] == "started", payload
|
|
inspect_calls = [c for c in calls if self._is_inspect_call(c)]
|
|
# At least the timeout + the success call.
|
|
assert len(inspect_calls) >= 2, inspect_calls
|
|
# --- Enable-retry edge cases (fork issue #493) ---
|
|
#
|
|
# Companion coverage to PR #1039's TestEnableRetry. These tests pin the
|
|
# three dispatch edge cases that #1039's contract introduces but does not
|
|
# directly exercise:
|
|
# a. retry path with no post_install hook → must reach 'started' without
|
|
# writing a 'setup_hook' transition;
|
|
# b. malformed progress JSON when /v1/extension/start arrives → handler
|
|
# must fall back to the synchronous compose path (not retry);
|
|
# c. progress.status == "setup_hook" (mid-install) → also falls back to
|
|
# the sync path; only "error" is the retry trigger.
|
|
#
|
|
# The retry helper from PR #1039 is now on main. These tests keep the
|
|
# dispatch edge cases pinned so future host-agent changes do not regress
|
|
# retry-versus-sync routing.
|
|
|
|
|
|
class TestEnableRetryEdgeCases:
|
|
|
|
def _setup_env(self, tmp_path, monkeypatch):
|
|
install_dir = tmp_path / "install"
|
|
install_dir.mkdir()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
builtin_root = tmp_path / "builtin"
|
|
user_root = tmp_path / "user"
|
|
builtin_root.mkdir()
|
|
user_root.mkdir()
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "DATA_DIR", data_dir)
|
|
monkeypatch.setattr(_mod, "EXTENSIONS_DIR", builtin_root)
|
|
monkeypatch.setattr(_mod, "USER_EXTENSIONS_DIR", user_root)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
# Drop the per-service lock so retries across tests don't deadlock.
|
|
_mod._service_locks.pop("fakesvc", None)
|
|
return data_dir, builtin_root
|
|
|
|
def _write_progress_raw(self, data_dir, sid, raw_text):
|
|
d = data_dir / "extension-progress"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / f"{sid}.json").write_text(raw_text, encoding="utf-8")
|
|
|
|
def _read_progress(self, data_dir, sid):
|
|
f = data_dir / "extension-progress" / f"{sid}.json"
|
|
if not f.exists():
|
|
return None
|
|
return json.loads(f.read_text(encoding="utf-8"))
|
|
|
|
def _body(self, sid):
|
|
return json.dumps({"service_id": sid}).encode("utf-8")
|
|
|
|
def test_no_hook_retry_completes_with_started_progress(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""error progress + manifest without post_install hook → retry skips
|
|
the setup_hook step and lands on 'started'.
|
|
|
|
Pins the no-hook branch in _enable_retry_work: when
|
|
_resolve_hook(ext_dir, "post_install") returns None, no
|
|
setup_hook progress write occurs and no subprocess is spawned;
|
|
the worker proceeds straight to docker_compose_action.
|
|
"""
|
|
# Run the retry worker thread synchronously so we can assert on
|
|
# final progress state from the test thread.
|
|
class _SyncThread:
|
|
def __init__(self, target=None, daemon=None, **kwargs):
|
|
self._target = target
|
|
|
|
def start(self):
|
|
self._target()
|
|
|
|
monkeypatch.setattr(_mod.threading, "Thread", _SyncThread)
|
|
|
|
data_dir, builtin_root = self._setup_env(tmp_path, monkeypatch)
|
|
ext_dir = builtin_root / "fakesvc"
|
|
ext_dir.mkdir()
|
|
# Manifest with NO post_install hook.
|
|
(ext_dir / "manifest.yaml").write_text(
|
|
"service:\n port: 1234\n", encoding="utf-8",
|
|
)
|
|
self._write_progress_raw(
|
|
data_dir, "fakesvc",
|
|
json.dumps({"service_id": "fakesvc", "status": "error",
|
|
"error": "prior failure"}),
|
|
)
|
|
|
|
hook_cmds: list = []
|
|
|
|
def fake_run(cmd, *a, **k):
|
|
hook_cmds.append(cmd)
|
|
import subprocess as _sp
|
|
# Startup_check (PR #1039) polls `docker inspect --format
|
|
# '{{.State.Status}}|{{.State.Error}}' ods-<svc>` after the
|
|
# compose action and reads stdout. Empty output keeps the poll
|
|
# in 'not running' forever and the retry path eventually writes
|
|
# progress='error'. Return a clean 'running|' for those calls
|
|
# so the poll satisfies and the worker reaches 'started';
|
|
# every other subprocess (which there shouldn't be in the
|
|
# no-hook branch) still gets an empty response.
|
|
is_docker_inspect = (
|
|
isinstance(cmd, list) and len(cmd) >= 2
|
|
and cmd[0] == "docker" and cmd[1] == "inspect"
|
|
)
|
|
stdout = "running|" if is_docker_inspect else ""
|
|
return _sp.CompletedProcess(args=cmd, returncode=0,
|
|
stdout=stdout, stderr="")
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(
|
|
_mod, "docker_compose_action",
|
|
lambda sid, action: (True, ""),
|
|
)
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
# Retry path engaged → 202 (accept-then-thread).
|
|
assert handler.response_code == 202
|
|
# No hook-script invocation: there is no hook to run. (The startup_check
|
|
# path from PR #1039 still calls subprocess.run for `docker inspect`
|
|
# to poll container state — filter those out and only assert no hook ran.)
|
|
hook_invocations = [
|
|
c for c in hook_cmds
|
|
if not (isinstance(c, list) and len(c) >= 2 and c[0] == "docker" and c[1] == "inspect")
|
|
]
|
|
assert hook_invocations == []
|
|
# Worker landed on 'started' (not 'setup_hook' or 'error').
|
|
progress = self._read_progress(data_dir, "fakesvc")
|
|
assert progress is not None
|
|
assert progress["status"] == "started"
|
|
|
|
def test_corrupted_progress_json_falls_back_to_sync_path(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""Malformed JSON in progress file → _read_progress_status returns
|
|
None → retry trigger is NOT engaged; the synchronous compose
|
|
path runs and the corrupted file is left untouched.
|
|
"""
|
|
data_dir, builtin_root = self._setup_env(tmp_path, monkeypatch)
|
|
ext_dir = builtin_root / "fakesvc"
|
|
ext_dir.mkdir()
|
|
(ext_dir / "manifest.yaml").write_text(
|
|
"service:\n port: 1234\n", encoding="utf-8",
|
|
)
|
|
self._write_progress_raw(data_dir, "fakesvc", "{not-json")
|
|
|
|
# Pre-condition: helper added by PR #1039 must report None for
|
|
# malformed JSON so the dispatch in _handle_extension cannot
|
|
# treat the corrupt state as "error" and trigger a retry.
|
|
assert _mod._read_progress_status("fakesvc") is None
|
|
|
|
compose_calls: list = []
|
|
|
|
def fake_compose(sid, act):
|
|
compose_calls.append((sid, act))
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_action", fake_compose)
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
# Synchronous success → 200, not 202.
|
|
assert handler.response_code == 200
|
|
assert compose_calls == [("fakesvc", "start")]
|
|
# Corrupted progress file left exactly as it was — the retry
|
|
# path would have rewritten it.
|
|
raw = (data_dir / "extension-progress" / "fakesvc.json").read_text(
|
|
encoding="utf-8",
|
|
)
|
|
assert raw == "{not-json"
|
|
|
|
def test_mid_install_setup_hook_status_uses_sync_path(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""status='setup_hook' is a mid-install state, not a terminal
|
|
error. The retry trigger only fires for status='error', so a
|
|
'start' against a service mid-install must use the sync path
|
|
and leave progress untouched.
|
|
"""
|
|
data_dir, builtin_root = self._setup_env(tmp_path, monkeypatch)
|
|
ext_dir = builtin_root / "fakesvc"
|
|
ext_dir.mkdir()
|
|
(ext_dir / "manifest.yaml").write_text(
|
|
"service:\n port: 1234\n", encoding="utf-8",
|
|
)
|
|
self._write_progress_raw(
|
|
data_dir, "fakesvc",
|
|
json.dumps({"service_id": "fakesvc", "status": "setup_hook"}),
|
|
)
|
|
|
|
# Pre-condition: the PR #1039 helper reports the in-flight status
|
|
# exactly so the dispatch can compare against the literal "error".
|
|
assert _mod._read_progress_status("fakesvc") == "setup_hook"
|
|
|
|
compose_calls: list = []
|
|
|
|
def fake_compose(sid, act):
|
|
compose_calls.append((sid, act))
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(_mod, "docker_compose_action", fake_compose)
|
|
|
|
handler = _FakeHandler(self._body("fakesvc"))
|
|
_mod.AgentHandler._handle_extension(handler, "start")
|
|
|
|
# Sync path used → 200, not 202.
|
|
assert handler.response_code == 200
|
|
assert compose_calls == [("fakesvc", "start")]
|
|
# Sync path must not rewrite the in-flight progress.
|
|
progress = self._read_progress(data_dir, "fakesvc")
|
|
assert progress is not None
|
|
assert progress["status"] == "setup_hook"
|
|
|
|
|
|
# --- Model download catalog unavailability (fork issue #512) ---
|
|
#
|
|
# Tests _handle_model_download's response shape when the model-library.json
|
|
# catalog is missing or corrupt. Pre-PR #1057 the handler conflates these
|
|
# real install-corruption cases with the policy denial "Model not in
|
|
# library catalog", returning 403 in all three. PR #1057 distinguishes
|
|
# unreadable/missing (500) from genuinely-not-listed (403).
|
|
#
|
|
# Cases (a) and (b) cover the post-#1057 corruption/error distinction.
|
|
# Case (c) is the existing-behaviour-preserved baseline for a clean catalog
|
|
# that simply does not list the requested model.
|
|
|
|
|
|
class TestModelDownloadCatalogUnavailable:
|
|
|
|
def _setup_env(self, tmp_path, monkeypatch):
|
|
install_dir = tmp_path / "install"
|
|
(install_dir / "config").mkdir(parents=True)
|
|
(install_dir / "data" / "models").mkdir(parents=True)
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
return install_dir
|
|
|
|
def _body(self):
|
|
return json.dumps({
|
|
"gguf_file": "test-model.gguf",
|
|
"gguf_url": "https://example.com/test-model.gguf",
|
|
}).encode("utf-8")
|
|
|
|
def test_missing_catalog_returns_500(self, tmp_path, monkeypatch):
|
|
"""No model-library.json → 500 'Model catalog unavailable'.
|
|
|
|
Pre-#1057 returns 403 (the catalog check sees library_path
|
|
missing, leaves allowed=False, falls through to the 'not in
|
|
library catalog' branch).
|
|
"""
|
|
install_dir = self._setup_env(tmp_path, monkeypatch)
|
|
assert not (install_dir / "config" / "model-library.json").exists()
|
|
|
|
handler = _FakeHandler(self._body())
|
|
_mod.AgentHandler._handle_model_download(handler)
|
|
|
|
assert handler.response_code == 500
|
|
body = handler.parse_response()
|
|
assert body["error"] == "Model catalog unavailable"
|
|
|
|
def test_corrupt_catalog_returns_500(self, tmp_path, monkeypatch):
|
|
"""Catalog file exists but is malformed JSON → 500.
|
|
|
|
Pre-#1057 the JSONDecodeError is swallowed by a bare ``pass``,
|
|
leaving allowed=False and returning 403 — masking the corrupt
|
|
install as a policy denial.
|
|
"""
|
|
install_dir = self._setup_env(tmp_path, monkeypatch)
|
|
(install_dir / "config" / "model-library.json").write_text(
|
|
"{not-json", encoding="utf-8",
|
|
)
|
|
|
|
handler = _FakeHandler(self._body())
|
|
_mod.AgentHandler._handle_model_download(handler)
|
|
|
|
assert handler.response_code == 500
|
|
body = handler.parse_response()
|
|
assert body["error"] == "Model catalog unavailable"
|
|
|
|
def test_model_not_in_clean_catalog_still_returns_403(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""Catalog parses cleanly and lists other models but not the one
|
|
being requested → 403 'Model not in library catalog' (the
|
|
existing policy-denial behaviour, preserved across #1057).
|
|
"""
|
|
install_dir = self._setup_env(tmp_path, monkeypatch)
|
|
(install_dir / "config" / "model-library.json").write_text(
|
|
json.dumps({"models": [{
|
|
"gguf_file": "different-model.gguf",
|
|
"gguf_url": "https://example.com/different.gguf",
|
|
}]}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
handler = _FakeHandler(self._body())
|
|
_mod.AgentHandler._handle_model_download(handler)
|
|
|
|
assert handler.response_code == 403
|
|
body = handler.parse_response()
|
|
assert body["error"] == "Model not in library catalog"
|
|
|
|
|
|
class TestModelDownloadFileIntegrity:
|
|
|
|
class _NoCancel:
|
|
def clear(self):
|
|
pass
|
|
|
|
def is_set(self):
|
|
return False
|
|
|
|
def wait(self, timeout=None):
|
|
return False
|
|
|
|
def _setup_env(self, tmp_path, monkeypatch, library_models=None):
|
|
install_dir = tmp_path / "install"
|
|
(install_dir / "config").mkdir(parents=True)
|
|
(install_dir / "data" / "models").mkdir(parents=True)
|
|
models = library_models or [{
|
|
"gguf_file": "test-model.gguf",
|
|
"gguf_url": "https://example.com/test-model.gguf",
|
|
"gguf_sha256": "",
|
|
}]
|
|
(install_dir / "config" / "model-library.json").write_text(
|
|
json.dumps({"models": models}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir)
|
|
monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key")
|
|
monkeypatch.setattr(_mod, "_model_download_thread", None)
|
|
monkeypatch.setattr(_mod, "_model_download_proc", None)
|
|
monkeypatch.setattr(_mod, "_model_download_cancel", self._NoCancel())
|
|
monkeypatch.setattr(
|
|
_mod.subprocess,
|
|
"run",
|
|
lambda cmd, *a, **kw: subprocess.CompletedProcess(
|
|
args=cmd,
|
|
returncode=0,
|
|
stdout="content-length: 123456\n",
|
|
stderr="",
|
|
),
|
|
)
|
|
return install_dir
|
|
|
|
def _body(self):
|
|
return json.dumps({
|
|
"gguf_file": "test-model.gguf",
|
|
"gguf_url": "https://example.com/test-model.gguf",
|
|
}).encode("utf-8")
|
|
|
|
def _patch_curl_download(self, monkeypatch, payload: bytes):
|
|
outputs = []
|
|
|
|
class FakeProc:
|
|
def __init__(self, cmd, *args, **kwargs):
|
|
self.cmd = cmd
|
|
self.returncode = None
|
|
self.output = Path(cmd[cmd.index("-o") + 1])
|
|
outputs.append(self.output.name)
|
|
|
|
def wait(self, timeout=None):
|
|
self.output.parent.mkdir(parents=True, exist_ok=True)
|
|
self.output.write_bytes(payload)
|
|
self.returncode = 0
|
|
return 0
|
|
|
|
def kill(self):
|
|
self.returncode = -9
|
|
|
|
monkeypatch.setattr(_mod.subprocess, "Popen", FakeProc)
|
|
return outputs
|
|
|
|
def test_empty_existing_model_is_redownloaded(self, tmp_path, monkeypatch):
|
|
install_dir = self._setup_env(tmp_path, monkeypatch)
|
|
self._patch_curl_download(monkeypatch, b"valid gguf bytes")
|
|
model_path = install_dir / "data" / "models" / "test-model.gguf"
|
|
model_path.write_bytes(b"")
|
|
|
|
handler = _FakeHandler(self._body())
|
|
_mod.AgentHandler._handle_model_download(handler)
|
|
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["status"] == "started"
|
|
_mod._model_download_thread.join(timeout=2)
|
|
assert not _mod._model_download_thread.is_alive()
|
|
assert model_path.read_bytes() == b"valid gguf bytes"
|
|
status = json.loads((install_dir / "data" / "model-download-status.json").read_text(encoding="utf-8"))
|
|
assert status["status"] == "complete"
|
|
|
|
def test_existing_model_clears_stale_downloading_status(self, tmp_path, monkeypatch):
|
|
install_dir = self._setup_env(tmp_path, monkeypatch)
|
|
model_path = install_dir / "data" / "models" / "test-model.gguf"
|
|
model_path.write_bytes(b"already here")
|
|
status_path = install_dir / "data" / "model-download-status.json"
|
|
status_path.write_text(
|
|
json.dumps({"status": "downloading", "model": "test-model.gguf"}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
handler = _FakeHandler(self._body())
|
|
_mod.AgentHandler._handle_model_download(handler)
|
|
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["status"] == "already_downloaded"
|
|
status = json.loads(status_path.read_text(encoding="utf-8"))
|
|
assert status["status"] == "complete"
|
|
assert status["model"] == "test-model.gguf"
|
|
|
|
def test_empty_finished_download_is_failed_not_complete(self, tmp_path, monkeypatch):
|
|
install_dir = self._setup_env(tmp_path, monkeypatch)
|
|
self._patch_curl_download(monkeypatch, b"")
|
|
|
|
handler = _FakeHandler(self._body())
|
|
_mod.AgentHandler._handle_model_download(handler)
|
|
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["status"] == "started"
|
|
_mod._model_download_thread.join(timeout=2)
|
|
assert not _mod._model_download_thread.is_alive()
|
|
status = json.loads((install_dir / "data" / "model-download-status.json").read_text(encoding="utf-8"))
|
|
assert status["status"] == "failed"
|
|
assert "missing or empty" in status["error"]
|
|
|
|
def test_split_download_skips_existing_non_empty_parts(self, tmp_path, monkeypatch):
|
|
parts = [
|
|
{
|
|
"file": "split-model-00001-of-00002.gguf",
|
|
"url": "https://example.com/split-model-00001-of-00002.gguf",
|
|
"sha256": "",
|
|
},
|
|
{
|
|
"file": "split-model-00002-of-00002.gguf",
|
|
"url": "https://example.com/split-model-00002-of-00002.gguf",
|
|
"sha256": "",
|
|
},
|
|
]
|
|
install_dir = self._setup_env(
|
|
tmp_path,
|
|
monkeypatch,
|
|
library_models=[{
|
|
"gguf_file": "split-model-00001-of-00002.gguf",
|
|
"gguf_url": "",
|
|
"gguf_sha256": "",
|
|
"gguf_parts": parts,
|
|
}],
|
|
)
|
|
calls = self._patch_curl_download(monkeypatch, b"downloaded second part")
|
|
models_dir = install_dir / "data" / "models"
|
|
(models_dir / "split-model-00001-of-00002.gguf").write_bytes(b"existing first part")
|
|
|
|
handler = _FakeHandler(json.dumps({
|
|
"gguf_file": "split-model-00001-of-00002.gguf",
|
|
"gguf_parts": parts,
|
|
}).encode("utf-8"))
|
|
_mod.AgentHandler._handle_model_download(handler)
|
|
|
|
assert handler.response_code == 200
|
|
assert handler.parse_response()["status"] == "started"
|
|
_mod._model_download_thread.join(timeout=2)
|
|
assert not _mod._model_download_thread.is_alive()
|
|
assert calls == ["split-model-00002-of-00002.gguf.part"]
|
|
assert (models_dir / "split-model-00001-of-00002.gguf").read_bytes() == b"existing first part"
|
|
assert (models_dir / "split-model-00002-of-00002.gguf").read_bytes() == b"downloaded second part"
|
|
status = json.loads((install_dir / "data" / "model-download-status.json").read_text(encoding="utf-8"))
|
|
assert status["status"] == "complete"
|