From 359beb906430060dab4148489862950c86a340fa Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:17:09 -0400 Subject: [PATCH 1/2] feat(cli): add sync status command sourceos-syncd sync status shows daemon and sync health at a glance: - daemon field: active | inactive | unknown (via systemctl is-active) - currentVersion: last applied content view version from ReceiptStore - lastReceipt: issuedAt, outcome, ageSeconds since issuedAt - storeReceipts: total receipt count - healthy: true iff daemon==active and last outcome in approved set Exit 0 if healthy, 1 if not. Accepts --store-root and --compact. Used by scripts/doctor.sh in source-os for the full health check. --- src/sourceos_syncd/cli.py | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/sourceos_syncd/cli.py b/src/sourceos_syncd/cli.py index fde5ca6..d13f8a3 100644 --- a/src/sourceos_syncd/cli.py +++ b/src/sourceos_syncd/cli.py @@ -190,6 +190,10 @@ def add_katello_args(p: argparse.ArgumentParser) -> None: sync_check_health.add_argument("--no-verify-ssl", action="store_true", help="skip TLS verification") add_compact(sync_check_health) + sync_status = sync_sub.add_parser("status", help="show daemon and sync status at a glance") + sync_status.add_argument("--store-root", default=None, help="store root (default: /var/lib/sourceos-syncd)") + add_compact(sync_status) + receipts = subcommands.add_parser("receipts", help="inspect persisted SyncCycleReceipts") receipts_sub = receipts.add_subparsers(dest="command", required=True) receipts_list = receipts_sub.add_parser("list", help="list recent receipts") @@ -431,6 +435,57 @@ def main(argv: list[str] | None = None) -> int: sys.stdout.write(pretty_json(output, pretty=pretty)) return 0 if healthy else 2 + if args.area == "sync" and args.command == "status": + import datetime + import subprocess as _sp + store_root = getattr(args, "store_root", None) or "/var/lib/sourceos-syncd" + store = ReceiptStore(root=store_root) + last = store.last_receipt() + current_version = store.read_current_version() + receipt_count = len(store.list_receipts(limit=100)) + + # Probe systemd without failing on non-systemd hosts. + try: + _r = _sp.run( + ["systemctl", "is-active", "sourceos-syncd"], + capture_output=True, text=True, timeout=3, + ) + daemon_state = _r.stdout.strip() or "unknown" + except Exception: + daemon_state = "unknown" + + last_receipt_summary: Any = None + if last: + issued = last.get("issuedAt", "") + age_s: int | None = None + try: + ts = datetime.datetime.fromisoformat(issued.replace("Z", "+00:00")) + now = datetime.datetime.now(datetime.timezone.utc) + age_s = int((now - ts).total_seconds()) + except Exception: + pass + last_receipt_summary = { + "issuedAt": issued, + "outcome": last.get("outcome"), + "ageSeconds": age_s, + } + + _good_outcomes = {"applied", "dry_run", "planned", "no_change"} + healthy = ( + daemon_state == "active" + and (last_receipt_summary is None + or last_receipt_summary.get("outcome") in _good_outcomes) + ) + status_payload: dict[str, Any] = { + "daemon": daemon_state, + "currentVersion": current_version, + "lastReceipt": last_receipt_summary, + "storeReceipts": receipt_count, + "healthy": healthy, + } + sys.stdout.write(pretty_json(status_payload, pretty=pretty)) + return 0 if healthy else 1 + if args.area == "receipts" and args.command == "list": store = ReceiptStore(root=getattr(args, "store_root", None) or "/var/lib/sourceos-syncd") receipts = store.list_receipts(limit=args.limit) From 9fe7e76774ff4840fafcffef87b56b4af1eb8e6c Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:44:48 -0400 Subject: [PATCH 2/2] test: sync status command coverage (12 cases) --- tests/test_cli_sync_status.py | 181 ++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/test_cli_sync_status.py diff --git a/tests/test_cli_sync_status.py b/tests/test_cli_sync_status.py new file mode 100644 index 0000000..481761b --- /dev/null +++ b/tests/test_cli_sync_status.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from sourceos_syncd.cli import main +from sourceos_syncd.receipt_store import ReceiptStore + +RECENT_ISO = "2026-06-16T12:00:00+00:00" + + +def _write_receipt(store: ReceiptStore, outcome: str, issued_at: str = RECENT_ISO) -> None: + store.write_receipt({ + "id": "receipt:test-abc", + "issuedAt": issued_at, + "outcome": outcome, + "version": "1.2", + }) + + +def _run(args: list[str]) -> tuple[int, dict]: + import io, sys + buf = io.StringIO() + with patch("sys.stdout", buf): + rc = main(args) + return rc, json.loads(buf.getvalue()) + + +# ── empty store ────────────────────────────────────────────────────────────── + + +def test_status_empty_store_no_daemon(tmp_path: Path) -> None: + with patch("subprocess.run", side_effect=FileNotFoundError): + rc, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + assert out["daemon"] == "unknown" + assert out["currentVersion"] is None + assert out["lastReceipt"] is None + assert out["storeReceipts"] == 0 + # daemon not active → not healthy + assert out["healthy"] is False + assert rc == 1 + + +# ── daemon active, receipt applied ────────────────────────────────────────── + + +def test_status_healthy(tmp_path: Path) -> None: + store = ReceiptStore(root=str(tmp_path)) + _write_receipt(store, "applied") + store.write_current_version("1.2") + + with patch("subprocess.run") as mock_sp: + mock_sp.return_value.stdout = "active\n" + mock_sp.return_value.returncode = 0 + rc, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + assert out["daemon"] == "active" + assert out["currentVersion"] == "1.2" + assert out["lastReceipt"]["outcome"] == "applied" + assert out["storeReceipts"] == 1 + assert out["healthy"] is True + assert rc == 0 + + +# ── outcome variants ───────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("outcome", ["dry_run", "planned", "no_change"]) +def test_status_healthy_for_non_applied_good_outcomes(outcome: str, tmp_path: Path) -> None: + store = ReceiptStore(root=str(tmp_path)) + _write_receipt(store, outcome) + + with patch("subprocess.run") as mock_sp: + mock_sp.return_value.stdout = "active\n" + mock_sp.return_value.returncode = 0 + rc, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + assert out["healthy"] is True + assert rc == 0 + + +def test_status_unhealthy_on_failed_outcome(tmp_path: Path) -> None: + store = ReceiptStore(root=str(tmp_path)) + _write_receipt(store, "failed") + + with patch("subprocess.run") as mock_sp: + mock_sp.return_value.stdout = "active\n" + mock_sp.return_value.returncode = 0 + rc, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + assert out["healthy"] is False + assert rc == 1 + + +def test_status_unhealthy_when_daemon_inactive(tmp_path: Path) -> None: + store = ReceiptStore(root=str(tmp_path)) + _write_receipt(store, "applied") + + with patch("subprocess.run") as mock_sp: + mock_sp.return_value.stdout = "inactive\n" + mock_sp.return_value.returncode = 3 + rc, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + assert out["daemon"] == "inactive" + assert out["healthy"] is False + assert rc == 1 + + +# ── last receipt summary fields ─────────────────────────────────────────────── + + +def test_status_receipt_age_seconds_is_non_negative(tmp_path: Path) -> None: + store = ReceiptStore(root=str(tmp_path)) + _write_receipt(store, "applied", issued_at=RECENT_ISO) + + with patch("subprocess.run") as mock_sp: + mock_sp.return_value.stdout = "active\n" + _, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + age = out["lastReceipt"]["ageSeconds"] + assert isinstance(age, int) + assert age >= 0 + + +def test_status_receipt_bad_timestamp_does_not_crash(tmp_path: Path) -> None: + store = ReceiptStore(root=str(tmp_path)) + store.write_receipt({"id": "receipt:x", "issuedAt": "not-a-date", "outcome": "applied"}) + + with patch("subprocess.run") as mock_sp: + mock_sp.return_value.stdout = "active\n" + rc, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + assert out["lastReceipt"]["ageSeconds"] is None + assert out["lastReceipt"]["outcome"] == "applied" + + +# ── multiple receipts ───────────────────────────────────────────────────────── + + +def test_status_store_receipts_counts_all(tmp_path: Path) -> None: + store = ReceiptStore(root=str(tmp_path)) + for i in range(5): + store.write_receipt({ + "id": f"receipt:test-{i:04d}", + "issuedAt": f"2026-06-16T12:0{i}:00+00:00", + "outcome": "applied", + }) + + with patch("subprocess.run") as mock_sp: + mock_sp.return_value.stdout = "active\n" + _, out = _run(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + + assert out["storeReceipts"] == 5 + + +# ── compact vs pretty output ───────────────────────────────────────────────── + + +def test_status_pretty_output_is_indented(tmp_path: Path) -> None: + import io, sys + buf = io.StringIO() + with patch("subprocess.run", side_effect=FileNotFoundError): + with patch("sys.stdout", buf): + main(["sync", "status", "--store-root", str(tmp_path)]) + raw = buf.getvalue() + # pretty JSON has newlines inside the object + assert "\n" in raw + + +def test_status_compact_output_is_single_line(tmp_path: Path) -> None: + import io, sys + buf = io.StringIO() + with patch("subprocess.run", side_effect=FileNotFoundError): + with patch("sys.stdout", buf): + main(["sync", "status", "--store-root", str(tmp_path), "--compact"]) + raw = buf.getvalue().strip() + assert "\n" not in raw