Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c8a18cb
feat(FAR-676): doctor full - extended checks, status --json enrichmen…
Sep 10, 2026
c6a0164
test: platform/env guards for POSIX-only doctor probes and live-DB up…
Sep 10, 2026
ade5fbc
test: fix architecture test-style violations in doctor tests
Sep 10, 2026
1fa73ec
fix(doctor): wire silent-stub probes + logs-rotate guard (FAR-676 rev…
Sep 10, 2026
1ecfc25
fix(doctor): wire unstubbed port/backup/TLS probes + logs rotate guar…
Sep 10, 2026
183158a
fix(doctor): honest TLS-skip message when no keypair generator has sh…
Sep 10, 2026
084a41a
fix(FAR-676): gate child-log rotation + persist bundled PG version (r…
Sep 10, 2026
b629432
Merge remote-tracking branch 'origin/main' into deliver/FAR-676
Sep 10, 2026
1593ef0
fix(db): stop 0209 re-adding collection_install_id column owned by 0207
Sep 10, 2026
9c3b435
docs(db): correct 0209 migration docstring to reflect 0207 owns the c…
Sep 10, 2026
f119d06
fix(launcher): compare bundle/data-dir PG version on shared prefix
Sep 10, 2026
2a5bc6d
fix(ci): ruff-format migration 0209 and replace empty-dict assertion …
Sep 10, 2026
74f2ca7
Merge remote-tracking branch 'origin/main' into work
Sep 10, 2026
c334513
Merge remote-tracking branch 'origin/main' into deliver/FAR-676
Sep 10, 2026
bea6f24
Merge remote-tracking branch 'origin/deliver/FAR-676' into deliver/FA…
Sep 10, 2026
a145aa7
test: raise new-code coverage for FAR-676 doctor/status/logs (SonarCl…
Sep 10, 2026
9b8364e
fix(ci): resolve test-suite-quality violations in launcher coverage t…
Sep 10, 2026
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
165 changes: 160 additions & 5 deletions backend/src/modulo/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

import json
import re
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -318,6 +320,12 @@
f"{name:<12} {pid if pid is not None else '-'!s:>8} "
f"{port if port is not None else '-'!s:>8} {str(bool(component.get('alive'))).lower()}"
)
remediation = component.get("remediation")
if remediation:
click.echo(f" hint ({name}): {remediation}")
degraded = payload.get("degraded")
if isinstance(degraded, dict):
click.echo(f"DEGRADED: {degraded.get('reason')}")


@cli.command("version")
Expand All @@ -334,22 +342,73 @@
help="Data dir override (default: the per-OS launcher root).",
)
@click.option("--json", "as_json", is_flag=True, default=False, help="Emit machine-readable JSON.")
@click.option(
"--report",
"report_path",
type=click.Path(path_type=Path),
default=None,
metavar="SHOP.zip",
help="Write a REDACTED diagnostic zip (versions, OS info, doctor output, data-dir log tails).",
)
@click.option(
"--fix",
is_flag=True,
default=False,
help="Apply orphan cleanup (safe sweeps only; a live postgres is never raced), then re-run the checks.",
)
@click.pass_context
def doctor(ctx: click.Context, data_dir: Path | None, as_json: bool) -> None:
"""Run the doctor-lite checks (exit 0 healthy, 1 unhealthy)."""
def doctor(
ctx: click.Context,
data_dir: Path | None,
as_json: bool,
report_path: Path | None,
fix: bool,
) -> None:
"""Doctor: exit 0 healthy, 1 unhealthy, 2 degraded (warnings), 3 uninitialized."""
_scrub_for_launcher_command()
import io

from modulo.launcher.doctor import run_doctor

resolved = _resolve_data_dir(data_dir)
capture: io.StringIO | None = None
if report_path is not None:
capture = io.StringIO()

def _tee(text: str) -> None:
# Capture for the report archive AND echo to the operator so the
# check table is still visible on the terminal (it would otherwise
# vanish into the report sink).
capture.write(text)
sys.stdout.write(text)

sink: Callable[[str], Any] | None = _tee
else:
sink = None
try:
code = run_doctor(_resolve_data_dir(data_dir), as_json=as_json)
code = run_doctor(resolved, as_json=as_json, fix=fix, sink=sink)
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc
if capture is not None and report_path is not None:
_build_doctor_report(resolved, report_path, capture.getvalue())
ctx.exit(code)


def _build_doctor_report(data_dir: Path, report_path: Path, doctor_output: str) -> None:
from modulo.launcher.doctor_report import build_report

click.echo(f"diagnostic report written: {build_report(data_dir, report_path, doctor_output=doctor_output)}")


@cli.command("env")
@click.option("--json", "as_json", is_flag=True, default=False, help="Emit machine-readable JSON.")
def env_cmd(as_json: bool) -> None:
@click.option(
"--raw",
is_flag=True,
default=False,
help="Escape hatch: print the effective Settings WITHOUT the redaction filter.",
)
def env_cmd(as_json: bool, raw: bool) -> None:
"""Print the effective Settings with every credential redacted."""
_scrub_for_launcher_command()
from modulo.settings import get_settings
Expand All @@ -358,14 +417,110 @@
settings = get_settings()
except Exception as exc:
raise click.ClickException(f"settings unavailable: {exc}") from exc
dump = _redacted_settings_dump(settings)
if raw:
dump = {name: str(getattr(settings, name)) for name in type(settings).model_fields}
if not as_json:
click.echo(
"WARNING: --raw prints the effective settings WITH every credential — never paste this output.",
err=True,
)
else:
dump = _redacted_settings_dump(settings)
if as_json:
click.echo(json.dumps(dump, indent=2, sort_keys=True))
return
for key in sorted(dump):
click.echo(f"{key}={dump[key]}")


@cli.command("logs")
@click.option(
"--data-dir",
type=click.Path(file_okay=False, path_type=Path),
default=None,
help="Data dir override (default: the per-OS launcher root).",
)
@click.option("-f", "--follow", is_flag=True, default=False, help="Follow the log (tail -f equivalent).")
@click.option(
"--rotate",
is_flag=True,
default=False,
help="Rotate the app log by size (N generations retained) INSTEAD of reading it (app only).",
)
@click.argument("component", required=False, default="app", type=click.Choice(["app", "postgres", "redis"]))
def logs(

Check failure on line 451 in backend/src/modulo/cli/main.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=farnalabs_modulo-new&issues=AaCMdo4nVBkTChWOibdy&open=AaCMdo4nVBkTChWOibdy&pullRequest=349
data_dir: Path | None,
follow: bool,
rotate: bool,
component: str,
) -> None:
"""Show a data-dir log: app (launcher/supervisor) or bundled postgres/redis child logs."""
_scrub_for_launcher_command()
import time

from modulo.launcher.supervisor import log_paths, rotate_log

resolved = _resolve_data_dir(data_dir)
path = log_paths(resolved)[component]
click.echo(f"# modulo {_package_version()} — {component} log: {path}")
if rotate:
if component != "app":
# Child logs (postgres/redis) are written-append by the running
# bundled process; rotating under a live holder redirects post-rename
# writes into the rotated-away inode. We do not rotate them.
click.echo("rotation applies to the app log only")
return
# rotate_log is only safe when no process holds the log open for
# appending; an attached launcher redirects post-rename writes into
# the rotated-away inode. Refuse rather than silently corrupt the log.
from modulo.launcher.supervisor import LOCK_SUFFIX, _pid_alive, _read_lock_holder

lock_path = resolved.parent / (resolved.name + LOCK_SUFFIX)
holder = _read_lock_holder(lock_path)
if holder is not None and _pid_alive(holder.pid):
click.echo(
"refused: the launcher is still running — rotating launcher.log while the "
"launcher holds it open for appending would redirect writes into the "
"rotated-away inode. Stop the launcher (`modulo stop`) first, then re-run "
"`modulo logs --rotate`.",
err=True,
)
raise SystemExit(1)
rotated = rotate_log(path)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: rotated = rotate_log(path) is OUTSIDE the else-branch, so modulo logs postgres --rotate (or redis) prints 'rotation applies to the app log only' and then rotates postgres.log/redis.log anyway. This contradicts the --rotate help text ('INSTEAD of reading it (app only)') and the safety design: the live-launcher guard only protects launcher.log, yet the bundled postgres/redis children write-append to their logs while running — rotating those redirects writes into the rotated-away inode (the exact hazard the comment describes). Move rotate_log + reporting into the else-branch (or guard child rotations by liveness too), and add a test that logs postgres --rotate does NOT rotate the child log.

if rotated:
click.echo(f"rotated: {path} -> {path.with_suffix(path.suffix + '.1')}")
else:
click.echo("no rotation (absent or below the size threshold)")
return
if not path.is_file():
click.echo(
f"no {component} log file in the data dir — an attached (foreground) launcher writes its "
"log to the terminal, not to a file; bundled children land logs in data-dir/logs/*."
)
raise SystemExit(1)
contents = path.read_text(encoding="utf-8", errors="replace")
if contents:
click.echo(contents, nl=False)
if follow:
click.echo("— following (Ctrl-C to stop) —")
offset = path.stat().st_size
try:
while True:
time.sleep(0.5)
size = path.stat().st_size
if size > offset:
click.echo(
path.read_bytes()[offset:].decode("utf-8", errors="replace"),
nl=False,
)
offset = size
except KeyboardInterrupt:
return
except OSError as exc:
click.echo(f"log write/read failed: {exc}")
raise SystemExit(1) from exc


# ---------------------------------------------------------------------------
# Service management (FAR-674 — systemd user unit, Linux only)
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading