From 79ca1be9883a17cc044a2e7396a5864adbb67392 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 11:46:22 +0200 Subject: [PATCH 1/2] feat(doctor): emit the checks as JSON so a deployment can be monitored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `doctor_cmd` formatted the checks as a column-aligned table and nothing else, so an operator who wants to know that their deployment stopped being able to send mail — before a participant fails to receive an invitation — had to scrape text whose width depends on the longest check name. `--json` prints one object per check with `name`, `state` and `detail`, as the whole of standard output and nothing else, so it can be piped straight into a parser. The exit code is untouched: non-zero when any check failed, in both modes, because a monitor reads both. Those three field names are now an interface. Renaming one breaks whatever is alerting on it, so `DOCTOR_JSON_FIELDS` names them in one place and the test asserts the emitted keys are exactly that tuple. The text format is what people read and has not moved: a new test rebuilds the expected table from `run_doctor()` and compares the command's stdout line for line. The secret-redaction test now covers the JSON path as well, since that output gets pasted into issues just as often. Closes #126 Signed-off-by: L4XB --- CHANGELOG.md | 3 + deploy/community/README.md | 12 ++++ services/api/src/sixsentences_server/cli.py | 32 ++++++++++- services/api/tests/test_doctor.py | 61 ++++++++++++++++++++- 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4998ef..95ccddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ equivalents for Python package metadata. - Every `sixsentences` subcommand accepts `--output PATH`. A file receives exactly the bytes stdout would have received, and is written only after the command succeeds. +- `six-community doctor --json` emits one object per check — `name`, `state` and + `detail` — as the whole of standard output, so a deployment can be monitored + rather than read. The exit code is unchanged in both modes. ### Changed diff --git a/deploy/community/README.md b/deploy/community/README.md index 561a0ae..ea95466 100644 --- a/deploy/community/README.md +++ b/deploy/community/README.md @@ -71,6 +71,18 @@ A feature that is switched off is reported as off rather than as broken, and no configured secret appears in the output, so the result can be pasted into an issue. `quickstart.sh` runs it once after the stack starts. +To watch the same answers from a monitor rather than read them, add `--json`: + +```console +docker compose --env-file .env.selfhost exec api six-community doctor --json +``` + +That prints one object per check — `name`, `state` (`ok`, `off` or `failed`) and +`detail` — as the whole of standard output, and nothing else, so it can be piped +straight into a parser. The exit code is the same in both modes: non-zero when +any check failed. Those three field names are a stable interface; alerting on +them is safe. + ## First account Public registration stays off until mail is configured, so the first account is diff --git a/services/api/src/sixsentences_server/cli.py b/services/api/src/sixsentences_server/cli.py index 7862882..7c0adc6 100644 --- a/services/api/src/sixsentences_server/cli.py +++ b/services/api/src/sixsentences_server/cli.py @@ -1270,11 +1270,41 @@ def api_cmd(port: int = typer.Option(8000)) -> None: app() +#: The field names `doctor --json` emits. They are an interface: a monitor +#: alerting on `state` breaks the day one of them is renamed, so treat a change +#: here the way a change to an API response is treated. +DOCTOR_JSON_FIELDS = ("name", "state", "detail") + + @app.command("doctor") -def doctor_cmd() -> None: +def doctor_cmd( + as_json: bool = typer.Option( + False, + "--json", + help="Emit the checks as JSON on stdout instead of the text table.", + ), +) -> None: """Report whether this deployment can do what it is configured for.""" checks: list[Check] = run_doctor() + + if as_json: + import json + + typer.echo( + json.dumps( + [ + {field: getattr(check, field) for field in DOCTOR_JSON_FIELDS} + for check in checks + ], + ensure_ascii=False, + ) + ) + # Nothing else goes to either stream: whatever reads this is parsing it. + if failed(checks): + raise typer.Exit(1) + return + width = max(len(check.name) for check in checks) for check in checks: marker = {"ok": "ok", "off": "off", "failed": "FAIL"}[check.state] diff --git a/services/api/tests/test_doctor.py b/services/api/tests/test_doctor.py index 1fea7b3..621ad93 100644 --- a/services/api/tests/test_doctor.py +++ b/services/api/tests/test_doctor.py @@ -2,11 +2,14 @@ from __future__ import annotations +import json from pathlib import Path import pytest +from typer.testing import CliRunner -from sixsentences_server.config import Settings +from sixsentences_server.cli import DOCTOR_JSON_FIELDS, app +from sixsentences_server.config import Settings, get_settings from sixsentences_server.core.db import init_db from sixsentences_server.ops.doctor import _mail_check, _storage_check, failed, run_doctor @@ -60,6 +63,62 @@ def test_no_configured_secret_reaches_the_output( # The enabled feature is still reported, just without its credential. assert "speech" in rendered + # The machine-readable form is pasted into issues just as often. + emitted = CliRunner().invoke(app, ["doctor", "--json"]).stdout + for canary in ("mail-user-canary", "mail-password-canary", "gemini-key-canary"): + assert canary not in emitted + + +def test_json_output_is_the_whole_of_stdout(settings: Settings) -> None: + """Whatever reads this is parsing it, so nothing else may share the stream.""" + init_db() + + result = CliRunner().invoke(app, ["doctor", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload + for entry in payload: + assert tuple(entry) == DOCTOR_JSON_FIELDS + assert entry["state"] in {"ok", "off", "failed"} + assert entry["detail"] + # The same questions the text table answers, in the same order. + assert [entry["name"] for entry in payload] == [check.name for check in run_doctor(settings)] + + +def test_json_output_keeps_the_exit_code( + settings: Settings, monkeypatch: pytest.MonkeyPatch +) -> None: + """A monitor reads both the body and the status.""" + monkeypatch.setenv("SIX_SMTP_HOST", "127.0.0.1") + monkeypatch.setenv("SIX_SMTP_PORT", "1") + # The command reads its own settings, and they are cached. + get_settings.cache_clear() + init_db() + + result = CliRunner().invoke(app, ["doctor", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert [entry for entry in payload if entry["state"] == "failed"] + + +def test_the_text_table_is_what_it_was(settings: Settings) -> None: + """--json is an addition; the format people read must not move.""" + init_db() + + result = CliRunner().invoke(app, ["doctor"]) + checks = run_doctor(settings) + width = max(len(check.name) for check in checks) + expected = [ + f"{ {'ok': 'ok', 'off': 'off', 'failed': 'FAIL'}[check.state]:>4} " + f"{check.name:<{width}} {check.detail}" + for check in checks + ] + + assert result.exit_code == 0 + assert result.stdout.splitlines() == [*expected, "", "Every configured feature answered."] + def test_unwritable_storage_is_reported_rather_than_raised(tmp_path: Path) -> None: blocker = tmp_path / "not-a-directory" From ed78f1c9045d5a9cbc8d3eb53678d6208c671aea Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 11:58:44 +0200 Subject: [PATCH 2/2] chore(api): refresh the community export manifest The audit pins a hash per exported file, so a change under services/api has to rewrite it. Signed-off-by: L4XB --- services/api/COMMUNITY_EXPORT_MANIFEST.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/api/COMMUNITY_EXPORT_MANIFEST.json b/services/api/COMMUNITY_EXPORT_MANIFEST.json index 72dabc1..2b8020b 100644 --- a/services/api/COMMUNITY_EXPORT_MANIFEST.json +++ b/services/api/COMMUNITY_EXPORT_MANIFEST.json @@ -319,10 +319,10 @@ "sha256": "362c3f72b1ce478603a3e2797c97ff8e9d66b1db13f9b49d84e171244e82b892" }, { - "bytes": 49196, + "bytes": 50068, "mode": "0644", "path": "src/sixsentences_server/cli.py", - "sha256": "e020cc2409ad3975e5988ed259ca718328718401ec7cc9f4512e1ed80dafeeb4" + "sha256": "286c94f8994ad124c1662564a37f3e094474ba74ed6e8f285aef84726710be47" }, { "bytes": 14232, @@ -1717,10 +1717,10 @@ "sha256": "92af76d32ab9409311ae0dab3c725d2f1d6270c55296be5a29557684fd740c75" }, { - "bytes": 2575, + "bytes": 4786, "mode": "0644", "path": "tests/test_doctor.py", - "sha256": "9f7dfac5f5479a3ba4281db19d1f41dd9a6be13f5804113bc1c724b325ce4280" + "sha256": "ec521a8f040e3ea3dd30d07ba70de252aecd19603e8b8d8109d1ae5585ffbd51" }, { "bytes": 62506,