From 3e38e3644b0fe14fd786ab668922538a7c924d70 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 10 Jul 2026 15:50:41 +0100 Subject: [PATCH] feat: report the running daemon's version (status + version command) --- stackvox/cli.py | 9 +++++++++ stackvox/daemon.py | 15 +++++++++++++++ tests/test_cli.py | 20 ++++++++++++++++++++ tests/test_daemon_protocol.py | 16 ++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/stackvox/cli.py b/stackvox/cli.py index d451539..5279503 100644 --- a/stackvox/cli.py +++ b/stackvox/cli.py @@ -389,9 +389,18 @@ def _cmd_stop(_: argparse.Namespace) -> int: def _cmd_status(_: argparse.Namespace) -> int: + installed = updates._current_version() if daemon.is_running(): print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}") rc = 0 + # The running daemon can lag the installed package if it wasn't restarted + # after an upgrade — surface that skew instead of trusting it blindly. + got, running_version = daemon.version() + if got and running_version != installed: + print( + f"daemon running {running_version}, but {installed} is installed " + f"— restart the daemon (stackvox stop; stackvox serve) to pick it up" + ) else: print("stopped") rc = 1 diff --git a/stackvox/daemon.py b/stackvox/daemon.py index 7b6f5bb..a0529f9 100644 --- a/stackvox/daemon.py +++ b/stackvox/daemon.py @@ -222,6 +222,10 @@ def handle(self) -> None: self.wfile.write(b"ok\n") return + if command == "version": + self.wfile.write(f"{updates._current_version()}\n".encode()) + return + if command == "stop": self.wfile.write(b"ok\n") threading.Thread(target=self.server.shutdown, daemon=True).start() @@ -343,3 +347,14 @@ def stop() -> tuple[bool, str]: def ping() -> tuple[bool, str]: return send({"command": "ping"}, timeout=PING_TIMEOUT_SECONDS) + + +def version(timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str]: + """(got_version, version_or_error) reported by the running daemon. + + The running daemon can differ from the installed package if it wasn't + restarted after an upgrade, so this reports what is actually serving — not + what's on disk. A version reply starts with a digit; error strings don't. + """ + _, resp = send({"command": "version"}, timeout=timeout) + return resp[:1].isdigit(), resp diff --git a/tests/test_cli.py b/tests/test_cli.py index cb7845a..ee23af0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -261,6 +261,9 @@ def test_running_prints_pid_and_returns_zero(self, mocker, capsys): pid_path.read_text.return_value = "12345\n" mocker.patch.object(cli.daemon, "PID_PATH", pid_path) mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock") + # status now queries the running daemon's version; stub it so the test + # doesn't reach a real socket. + mocker.patch.object(cli.daemon, "version", return_value=(False, "n/a")) rc = cli._cmd_status(_ns()) @@ -269,6 +272,23 @@ def test_running_prints_pid_and_returns_zero(self, mocker, capsys): assert "running" in out assert "12345" in out + def test_running_flags_daemon_version_skew(self, mocker, capsys): + mocker.patch.object(cli.daemon, "is_running", return_value=True) + pid_path = mocker.MagicMock() + pid_path.read_text.return_value = "1\n" + mocker.patch.object(cli.daemon, "PID_PATH", pid_path) + mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock") + mocker.patch.object(cli.daemon, "version", return_value=(True, "0.5.0")) + mocker.patch.object(cli.updates, "_current_version", return_value="0.8.0") + mocker.patch.object(cli.updates, "check_for_update", return_value=None) + + rc = cli._cmd_status(_ns()) + + assert rc == 0 + out = capsys.readouterr().out + assert "0.5.0" in out and "0.8.0" in out # the skew is surfaced + assert "restart" in out.lower() + def test_stopped_returns_one(self, mocker, capsys): mocker.patch.object(cli.daemon, "is_running", return_value=False) rc = cli._cmd_status(_ns()) diff --git a/tests/test_daemon_protocol.py b/tests/test_daemon_protocol.py index f066408..0a906ee 100644 --- a/tests/test_daemon_protocol.py +++ b/tests/test_daemon_protocol.py @@ -76,6 +76,22 @@ def test_ping_returns_ok(server: ServerHarness): assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok" +def test_version_returns_installed_version(server: ServerHarness): + from stackvox import updates + + reply = _roundtrip(server.sock, json.dumps({"command": "version"}) + "\n") + assert reply == updates._current_version() + + +def test_version_client_helper_reports_running_version(server: ServerHarness, monkeypatch): + from stackvox import daemon, updates + + monkeypatch.setattr(daemon, "SOCKET_PATH", server.sock) + got, resp = daemon.version() + assert got is True + assert resp == updates._current_version() + + def test_plain_text_is_treated_as_text_field(server: ServerHarness): """Non-JSON payloads are accepted and wrapped as `{"text": line}`.""" import time