Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions deploy/community/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions services/api/COMMUNITY_EXPORT_MANIFEST.json
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,10 @@
"sha256": "362c3f72b1ce478603a3e2797c97ff8e9d66b1db13f9b49d84e171244e82b892"
},
{
"bytes": 49196,
"bytes": 50068,
"mode": "0644",
"path": "src/sixsentences_server/cli.py",
"sha256": "e020cc2409ad3975e5988ed259ca718328718401ec7cc9f4512e1ed80dafeeb4"
"sha256": "286c94f8994ad124c1662564a37f3e094474ba74ed6e8f285aef84726710be47"
},
{
"bytes": 14232,
Expand Down Expand Up @@ -1717,10 +1717,10 @@
"sha256": "92af76d32ab9409311ae0dab3c725d2f1d6270c55296be5a29557684fd740c75"
},
{
"bytes": 2575,
"bytes": 4786,
"mode": "0644",
"path": "tests/test_doctor.py",
"sha256": "9f7dfac5f5479a3ba4281db19d1f41dd9a6be13f5804113bc1c724b325ce4280"
"sha256": "ec521a8f040e3ea3dd30d07ba70de252aecd19603e8b8d8109d1ae5585ffbd51"
},
{
"bytes": 62506,
Expand Down
32 changes: 31 additions & 1 deletion services/api/src/sixsentences_server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
61 changes: 60 additions & 1 deletion services/api/tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down