diff --git a/solstone/think/health_cli.py b/solstone/think/health_cli.py index 25373cc34..d6046f043 100644 --- a/solstone/think/health_cli.py +++ b/solstone/think/health_cli.py @@ -95,15 +95,17 @@ def print_status(status: dict[str, Any]) -> None: print(f"Callosum: {callosum_clients} clients") -def health_check() -> int: - """Request and print one-shot supervisor status.""" +def fetch_supervisor_status(timeout: float = STATUS_TIMEOUT) -> dict[str, Any] | None: + """Request one-shot supervisor status from callosum; return it as data. + + Returns the supervisor/status event dict on success, or None if the + callosum socket is absent or no status arrives within ``timeout`` + seconds. Performs no printing and never exits — safe for programmatic + callers (e.g. ``journal doctor``). + """ sock_path = Path(get_journal()) / "health" / "callosum.sock" if not sock_path.exists(): - print( - f"Cannot connect: callosum socket not found at {sock_path}", - file=sys.stderr, - ) - return 1 + return None status_event = threading.Event() status_holder: dict[str, dict[str, Any]] = {} @@ -116,18 +118,34 @@ def health_check() -> int: conn = CallosumConnection(socket_path=sock_path) conn.start(callback=callback) try: - got_status = status_event.wait(timeout=STATUS_TIMEOUT) + got_status = status_event.wait(timeout=timeout) finally: conn.stop() if not got_status: + return None + return status_holder["data"] + + +def health_check() -> int: + """Request and print one-shot supervisor status.""" + sock_path = Path(get_journal()) / "health" / "callosum.sock" + if not sock_path.exists(): + print( + f"Cannot connect: callosum socket not found at {sock_path}", + file=sys.stderr, + ) + return 1 + + status = fetch_supervisor_status(timeout=STATUS_TIMEOUT) + if status is None: print( f"Timed out waiting for supervisor status ({STATUS_TIMEOUT:g}s)", file=sys.stderr, ) return 1 - print_status(status_holder["data"]) + print_status(status) return 0 diff --git a/solstone/think/service.py b/solstone/think/service.py index 3b109f174..2a530a9e6 100644 --- a/solstone/think/service.py +++ b/solstone/think/service.py @@ -30,7 +30,10 @@ from dataclasses import dataclass from pathlib import Path from xml.parsers.expat import ExpatError -from solstone.think.install_guard import validate_journal_path_for_wrapper +from solstone.think.install_guard import ( + parse_wrapper, + validate_journal_path_for_wrapper, +) from solstone.think.readiness import clear_ready, wait_ready from solstone.think.utils import get_journal, get_journal_info @@ -48,6 +51,17 @@ class Reconciled: canonical_path: Path | None +@dataclass(frozen=True) +class ServiceTargetIdentity: + """Read-only report on which install the installed service will invoke.""" + + installed: bool + target: str + resolved_target: str + matches_current_install: bool + detail: str + + def _ready_timeout_message() -> str: return ( f"Service did not become ready within {READY_TIMEOUT_SECONDS:g}s — " @@ -191,6 +205,26 @@ def _systemd_exec_start_parts(path: Path) -> tuple[list[str] | None, list[str]]: return parts, lines +def _launchd_program_arguments(path: Path) -> list[str] | None: + """Return a launchd plist's ProgramArguments as strings (read-only). + + Returns None when the plist cannot be parsed or has no ProgramArguments + list. Mirrors the inline read in ``_reconcile_launchd_plist`` but is a + pure reader: it never prints or mutates. + """ + try: + with path.open("rb") as handle: + data = plistlib.load(handle) + except (plistlib.InvalidFileException, ValueError, ExpatError, OSError): + return None + + program_arguments = data.get("ProgramArguments") + if not isinstance(program_arguments, list): + return None + + return [str(arg) for arg in program_arguments] + + def _classify_unit_args(args: list[str]) -> tuple[str, str, list[str], bool] | None: if len(args) < 2: return None @@ -301,6 +335,83 @@ def reconcile_installed_unit() -> Reconciled: return Reconciled(False, None, None, None) +def _resolve_service_target(target: str) -> Path: + """Resolve a service-target binary to its real executable (read-only). + + The target is typically ``~/.local/bin/journal``, which may be: + (a) a managed bash wrapper (source-checkout install) — not a symlink; + parse it for the embedded ``sol_bin`` venv binary; + (b) a symlink (packaged uv-tool / pipx install) — follow it; + (c) a plain executable — resolve as-is. + """ + path = Path(target) + if path.is_symlink(): + return path.resolve() + if path.is_file(): + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + pass + else: + parsed = parse_wrapper(content) + if parsed: + return Path(parsed["sol_bin"]).resolve() + return path.resolve() + + +def check_service_target_identity() -> ServiceTargetIdentity: + """Report whether the installed user service targets the current install. + + Read-only. Inspects the launchd plist (darwin) or systemd unit (linux), + extracts the service-target binary, resolves it through the managed + wrapper / symlink, and compares it to the running install's bin + directory. Returns a clean not-installed result (not an exception) when + no service is installed or the platform is unsupported. + """ + if sys.platform == "darwin": + path = _plist_path() + if not path.exists(): + return ServiceTargetIdentity(False, "", "", False, "service not installed") + parts = _launchd_program_arguments(path) + elif sys.platform.startswith("linux"): + path = _unit_path() + if not path.exists(): + return ServiceTargetIdentity(False, "", "", False, "service not installed") + parts, _lines = _systemd_exec_start_parts(path) + else: + return ServiceTargetIdentity(False, "", "", False, "service not installed") + + if not parts: + return ServiceTargetIdentity( + True, + "", + "", + False, + f"service unit is malformed: no executable target in {path}", + ) + + raw_target = str(parts[0]) + resolved = _resolve_service_target(raw_target) + expected = (Path(sys.executable).parent / "journal").resolve() + matches = resolved == expected + + if matches: + detail = f"service target matches current install: {raw_target} -> {resolved}" + else: + detail = ( + "service target mismatch: " + f"{raw_target} resolves to {resolved}, expected {expected}" + ) + + return ServiceTargetIdentity( + True, + raw_target, + str(resolved), + matches, + detail, + ) + + def remove_stale_plists() -> tuple[int, int]: """Remove stale launchd plists from prior installs.""" if sys.platform != "darwin": diff --git a/tests/test_health_cli.py b/tests/test_health_cli.py index e02ede3db..43fa1fc81 100644 --- a/tests/test_health_cli.py +++ b/tests/test_health_cli.py @@ -8,7 +8,13 @@ from unittest.mock import patch import pytest -from solstone.think.health_cli import health_check, main, print_status +from solstone.think import health_cli +from solstone.think.health_cli import ( + fetch_supervisor_status, + health_check, + main, + print_status, +) def test_health_check_no_socket(tmp_path, monkeypatch, capsys): @@ -21,6 +27,73 @@ def test_health_check_no_socket(tmp_path, monkeypatch, capsys): assert "callosum socket not found" in captured.err +def test_fetch_supervisor_status_returns_dict(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + sock = tmp_path / "health" / "callosum.sock" + sock.parent.mkdir(parents=True) + sock.touch() + status = { + "tract": "supervisor", + "event": "status", + "services": [{"name": "supervisor", "pid": 111, "uptime_seconds": 120}], + "crashed": [], + "tasks": [], + "queues": {}, + "stale_heartbeats": [], + "schedules": [], + "callosum_clients": 1, + } + + with patch("solstone.think.health_cli.CallosumConnection") as mock_conn_cls: + mock_conn = mock_conn_cls.return_value + + def _start(*, callback): + callback(status) + + mock_conn.start.side_effect = _start + mock_conn.stop.return_value = None + + result = fetch_supervisor_status(timeout=health_cli.STATUS_TIMEOUT) + + assert result is status + mock_conn.stop.assert_called_once() + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_fetch_supervisor_status_no_socket(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + + result = fetch_supervisor_status() + + assert result is None + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_fetch_supervisor_status_timeout(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + sock = tmp_path / "health" / "callosum.sock" + sock.parent.mkdir(parents=True) + sock.touch() + monkeypatch.setattr("solstone.think.health_cli.STATUS_TIMEOUT", 0.1) + + with patch("solstone.think.health_cli.CallosumConnection") as mock_conn_cls: + mock_conn = mock_conn_cls.return_value + mock_conn.start.return_value = None + mock_conn.stop.return_value = None + + result = fetch_supervisor_status(timeout=health_cli.STATUS_TIMEOUT) + + assert result is None + mock_conn.stop.assert_called_once() + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + def test_health_check_prints_status(capsys): status = { "services": [ diff --git a/tests/test_service_target_identity.py b/tests/test_service_target_identity.py new file mode 100644 index 000000000..d3fb93745 --- /dev/null +++ b/tests/test_service_target_identity.py @@ -0,0 +1,219 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import plistlib +import shlex +import sys +from pathlib import Path + +import pytest + +from solstone.think import install_guard, service + +PLATFORMS = ("darwin", "linux") + + +def _touch(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + +def _service_path(tmp_path: Path, platform: str) -> Path: + if platform == "darwin": + return tmp_path / "org.solpbc.solstone.plist" + return tmp_path / "solstone.service" + + +def _write_service_definition(path: Path, platform: str, target: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if platform == "darwin": + path.write_bytes( + plistlib.dumps( + { + "Label": service.SERVICE_LABEL, + "ProgramArguments": [target, "start", "5015"], + } + ) + ) + return + + path.write_text( + "[Unit]\n" + "Description=Solstone Supervisor\n" + "[Service]\n" + f"ExecStart={shlex.join([target, 'start', '5015'])}\n", + encoding="utf-8", + ) + + +def _patch_platform( + monkeypatch: pytest.MonkeyPatch, + *, + platform: str, + service_path: Path, + executable: Path, +) -> None: + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr(sys, "executable", str(executable)) + if platform == "darwin": + monkeypatch.setattr(service, "_plist_path", lambda: service_path) + else: + monkeypatch.setattr(service, "_unit_path", lambda: service_path) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_target_matches_via_wrapper( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, platform: str +) -> None: + install_bin = tmp_path / "install" / "bin" + install_bin.mkdir(parents=True) + sol_bin = install_bin / "journal" + _touch(sol_bin) + wrapper = tmp_path / "local" / "bin" / "journal" + wrapper.parent.mkdir(parents=True) + wrapper.write_text( + install_guard.render_wrapper( + str(tmp_path / "journal"), str(sol_bin), "journal" + ), + encoding="utf-8", + ) + service_path = _service_path(tmp_path, platform) + _write_service_definition(service_path, platform, str(wrapper)) + _patch_platform( + monkeypatch, + platform=platform, + service_path=service_path, + executable=install_bin / "python", + ) + + result = service.check_service_target_identity() + + assert result.installed + assert result.matches_current_install + assert result.resolved_target == str(sol_bin.resolve()) + assert result.target == str(wrapper) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_target_mismatch_via_wrapper( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, platform: str +) -> None: + install_bin = tmp_path / "install" / "bin" + install_bin.mkdir(parents=True) + other_bin = tmp_path / "other" / "bin" + other_bin.mkdir(parents=True) + sol_bin = other_bin / "journal" + _touch(sol_bin) + wrapper = tmp_path / "local" / "bin" / "journal" + wrapper.parent.mkdir(parents=True) + wrapper.write_text( + install_guard.render_wrapper( + str(tmp_path / "journal"), str(sol_bin), "journal" + ), + encoding="utf-8", + ) + service_path = _service_path(tmp_path, platform) + _write_service_definition(service_path, platform, str(wrapper)) + _patch_platform( + monkeypatch, + platform=platform, + service_path=service_path, + executable=install_bin / "python", + ) + expected = (install_bin / "journal").resolve() + + result = service.check_service_target_identity() + + assert result.installed + assert not result.matches_current_install + assert str(sol_bin.resolve()) in result.detail + assert str(expected) in result.detail + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_target_matches_via_symlink( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, platform: str +) -> None: + install_bin = tmp_path / "install" / "bin" + install_bin.mkdir(parents=True) + sol_bin = install_bin / "journal" + _touch(sol_bin) + wrapper = tmp_path / "local" / "bin" / "journal" + wrapper.parent.mkdir(parents=True) + wrapper.symlink_to(sol_bin) + service_path = _service_path(tmp_path, platform) + _write_service_definition(service_path, platform, str(wrapper)) + _patch_platform( + monkeypatch, + platform=platform, + service_path=service_path, + executable=install_bin / "python", + ) + + result = service.check_service_target_identity() + + assert result.installed + assert result.matches_current_install + assert result.resolved_target == str(sol_bin.resolve()) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_not_installed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, platform: str +) -> None: + install_bin = tmp_path / "install" / "bin" + service_path = _service_path(tmp_path, platform) + _patch_platform( + monkeypatch, + platform=platform, + service_path=service_path, + executable=install_bin / "python", + ) + + result = service.check_service_target_identity() + + assert not result.installed + assert result.target == "" + assert result.resolved_target == "" + assert not result.matches_current_install + assert result.detail == "service not installed" + + +def test_malformed_unit_darwin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + install_bin = tmp_path / "install" / "bin" + plist_path = tmp_path / "org.solpbc.solstone.plist" + plist_path.write_bytes(plistlib.dumps({"Label": service.SERVICE_LABEL})) + _patch_platform( + monkeypatch, + platform="darwin", + service_path=plist_path, + executable=install_bin / "python", + ) + + result = service.check_service_target_identity() + + assert result.installed + assert result.target == "" + assert not result.matches_current_install + assert str(plist_path) in result.detail + + +def test_malformed_unit_linux(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + install_bin = tmp_path / "install" / "bin" + unit_path = tmp_path / "solstone.service" + unit_path.write_text('[Service]\nExecStart="unterminated\n', encoding="utf-8") + _patch_platform( + monkeypatch, + platform="linux", + service_path=unit_path, + executable=install_bin / "python", + ) + + result = service.check_service_target_identity() + + assert result.installed + assert result.target == "" + assert not result.matches_current_install + assert str(unit_path) in result.detail