From ceba40e68b525c1436c26a194ef05f2076462674 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 13 May 2026 21:02:03 +0200 Subject: [PATCH 01/13] docs(adr-010): establish global CLI output contract Add ADR-T-010 as the canonical repository-wide stdout/stderr contract for first-party command-line entrypoints. This extracts the helper-binary P8/P9 rules from ADR-T-009 into a global JSON-only stream contract: stdout is reserved for result data, stderr carries diagnostics/control records, and stdout-producing commands refuse direct TTY output. - Add the ADR-T-010 decision record and a conformance plan covering command classification, shared CLI infrastructure, logging, panic/help/usage handling, root binaries, helper binaries, the container entry script, docs, and tests. - Update the changelog, README, ADR-T-007, and ADR-T-009 so the helper stdout/stderr rules now point at ADR-T-010 while ADR-T-009 remains the container-infrastructure history. - Refresh rustdoc and comments in the helper/config/JWT code paths to name ADR-T-010 instead of the older P8/P9 phase shorthand. No runtime behaviour changes; this records the global contract and the follow-up migration plan. --- CHANGELOG.md | 12 +- README.md | 1 + adr/007-jwt-system-refactor.md | 2 +- adr/009-container-infrastructure-refactor.md | 34 +- ...010-global-command-line-output-contract.md | 90 ++++ ...10-command-line-output-conformance-plan.md | 472 ++++++++++++++++++ packages/index-auth-keypair/src/lib.rs | 2 +- packages/index-cli-common/src/lib.rs | 8 +- packages/index-cli-common/tests/public_api.rs | 2 +- packages/index-config/src/lib.rs | 4 +- .../src/bin/torrust-index-health-check.rs | 4 +- src/jwt.rs | 5 +- 12 files changed, 604 insertions(+), 32 deletions(-) create mode 100644 adr/010-global-command-line-output-contract.md create mode 100644 docs/plans/adr-010-command-line-output-conformance-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d21ccfd..4dc2419c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -**Highlights:** container infrastructure refactor (ADR-T-009), +**Highlights:** global command-line output contract (ADR-T-010), +container infrastructure refactor (ADR-T-009), native role-based authorization replacing Casbin (ADR-T-008), RSA-signed JWTs with revocation support (ADR-T-007), domain-scoped error system (ADR-T-006), MSRV raised to 1.88. @@ -57,6 +58,13 @@ error system (ADR-T-006), MSRV raised to 1.88. domain-scoped enums: `AuthError`, `UserError`, `TorrentError`, `CategoryTagError`, with a thin `ApiError` wrapper (ADR-T-006). +### ADR-T-010 — Global command-line output contract + +#### Added + +- ADR-T-010, extracting ADR-T-009's helper stdout/stderr convention into + a global command-line output contract for the application. + ### ADR-T-009 — Container infrastructure refactor #### Added @@ -67,7 +75,7 @@ error system (ADR-T-006), MSRV raised to 1.88. configuration system. Leaf crate — no `tokio`, `reqwest`, `sqlx`, `hyper`, `rustls`, `native-tls`, or `openssl` in its dep closure. - Helper-binary crates split into leaves with no HTTP/TLS in their - dep closure: `torrust-index-cli-common` (shared P9 scaffolding — + dep closure: `torrust-index-cli-common` (shared ADR-T-010 scaffolding — `refuse_if_stdout_is_tty`, `init_json_tracing`, `emit`, `BaseArgs`), `torrust-index-health-check` (stdlib-only, Happy Eyeballs IPv6/IPv4 fallback), `torrust-index-auth-keypair` (RSA-2048 key generator), diff --git a/README.md b/README.md index 0205d023..cc5be155 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ The following services are provided by the default configuration: - [ADR-T-007: Refactor the JWT System](adr/007-jwt-system-refactor.md) — Centralise JWT handling into `src/jwt.rs`, redesign claims to RFC 7519, move to RS256 asymmetric signing, and consolidate session validation into a single code path. - [ADR-T-008: Refactor the Roles and Permissions System](adr/008-roles-and-permissions-refactor.md) — Replace Casbin with a native Rust permission system (`PermissionMatrix` + `RequirePermission` Axum extractors), migrate from `administrator: bool` to a `role` column, and add a `/me/permissions` discovery endpoint. - [ADR-T-009: Container Infrastructure Refactor](adr/009-container-infrastructure-refactor.md) — Split the runtime image into `release` (distroless, root-only toolset) and `debug` bases; extract three helper binaries (`torrust-index-health-check`, `torrust-index-auth-keypair`, `torrust-index-config-probe`) into their own workspace crates with no HTTP/TLS/async-runtime deps; strip credentials from shipped TOMLs and make `database.connect_url` / `tracker.token` mandatory schema fields; split Compose into a production-shaped `compose.yaml` baseline plus an auto-loaded `compose.override.yaml` dev sandbox; and add an internal audit record for vendored `su-exec`. +- [ADR-T-010: Global Command-Line Output Contract](adr/010-global-command-line-output-contract.md) — Apply the JSON-only stdout/stderr contract across first-party command-line entrypoints: stdout is result JSON, stderr is diagnostic JSON/NDJSON, and commands with stdout result data refuse direct TTY output. ## Contributing diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index fc6c9eb3..a4afa575 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -226,7 +226,7 @@ longer exists. Design: - Refuses to run if stdout is a terminal (exit code 2). - Emits a single JSON object `{"private_key_pem": "...", "public_key_pem": "..."}` - on stdout (P9 of ADR-T-009). The original raw-PEM + on stdout ([ADR-T-010](010-global-command-line-output-contract.md)). The original raw-PEM output was replaced in Phase 2. - Diagnostics on stderr via `tracing` (NDJSON); `--debug` for verbose. - Uses `clap` for CLI. diff --git a/adr/009-container-infrastructure-refactor.md b/adr/009-container-infrastructure-refactor.md index ad585c57..2393349c 100644 --- a/adr/009-container-infrastructure-refactor.md +++ b/adr/009-container-infrastructure-refactor.md @@ -3,7 +3,7 @@ **Status:** Implemented **Date:** 2026-04-19 **Supersedes:** Earlier `ADR-T-009` draft ("Container Infrastructure Hardening") whose tactical S-N items were merged without a written ADR file. Those items are summarised in [Prior Work](#prior-work) and are not re-litigated here. -**Relates to:** [ADR-T-007](007-jwt-system-refactor.md) (auth key generation performed by the entry script). +**Relates to:** [ADR-T-007](007-jwt-system-refactor.md) (auth key generation performed by the entry script), [ADR-T-010](010-global-command-line-output-contract.md) (global command-line output contract). --- @@ -49,8 +49,8 @@ The decisions below follow from a small set of invariants the container subsyste - **P5.** Where two components must agree on a value (path, port, credential), exactly one of them owns it and tells the other; they do not independently maintain a shared constant. - **P6.** The compose baseline is production-shaped; dev affordances are an additive override layer, never a subtraction from the baseline. - **P7.** Vendored security-sensitive code is treated as code we own, with a current internal audit record. -- **P8.** No machine-readable stdout to a TTY. Every helper binary that emits structured output (JSON, PEM) on stdout refuses to run when stdout is a terminal. The check is unconditional — it does not depend on whether the specific output is sensitive. Operators who want to see the output interactively pipe to `jq`, `less`, or `cat`. -- **P9.** Universal helper conventions. Every helper binary links the same baseline crates without exception or per-crate justification: `clap` (argv), `tracing` + `tracing-subscriber` with `json` feature (stderr diagnostics), `serde` + `serde_json` (stdout wire format). These are not enumerated in per-crate allowlists. On success (exit 0), stdout is one JSON object followed by one trailing newline. On failure (exit ≠ 0), stdout is empty — the exit code is the sole branch signal for callers, and the diagnostic goes to stderr via `tracing`. Stderr is always NDJSON `tracing` events regardless of exit code. A shared `torrust-index-cli-common` library crate provides the scaffolding (`refuse_if_stdout_is_tty`, `init_json_tracing`, `emit`, and a common `BaseArgs` with `--debug`). +- **P8.** Helper binaries implement the TTY-refusal rule now defined globally by [ADR-T-010](010-global-command-line-output-contract.md): commands that emit stdout result data refuse to write it directly to a terminal. +- **P9.** Helper binaries implement the stdout/stderr contract now defined globally by [ADR-T-010](010-global-command-line-output-contract.md). This ADR keeps one helper-specific dependency consequence: every helper binary links the same baseline crates without exception or per-crate justification: `clap` (argv), `tracing` + `tracing-subscriber` with `json` feature (stderr diagnostics), `serde` + `serde_json` (stdout wire format). These are not enumerated in per-crate allowlists. A shared `torrust-index-cli-common` library crate provides the scaffolding (`refuse_if_stdout_is_tty`, `init_json_tracing`, `emit`, and a common `BaseArgs` with `--debug`). --- @@ -261,7 +261,7 @@ The script does not poll env vars to discover the configuration. A small `torrus A workspace crate `packages/index-config-probe/` (binary `torrust-index-config-probe`) loads the same `Settings` the application loads and emits the container-relevant resolved values as a JSON object on stdout. -**Dependencies.** `torrust-index-config` (path dependency) and `torrust-index-cli-common` (P9 scaffolding). The helper inherits the parsing surface (`figment`, `toml`, `serde`, `serde_with`, `url`, `camino`, `derive_more`, `thiserror`, `tracing`) via `torrust-index-config`; it adds direct `url` and `percent-encoding` deps for sqlite-URL path-extraction logic. `figment` is declared with `default-features = false` and an explicit feature allowlist (`toml`, `env`) in `torrust-index-config`'s `Cargo.toml` so a future feature flip cannot smuggle `tokio` in transitively. +**Dependencies.** `torrust-index-config` (path dependency) and `torrust-index-cli-common` (ADR-T-010 scaffolding). The helper inherits the parsing surface (`figment`, `toml`, `serde`, `serde_with`, `url`, `camino`, `derive_more`, `thiserror`, `tracing`) via `torrust-index-config`; it adds direct `url` and `percent-encoding` deps for sqlite-URL path-extraction logic. `figment` is declared with `default-features = false` and an explicit feature allowlist (`toml`, `env`) in `torrust-index-config`'s `Cargo.toml` so a future feature flip cannot smuggle `tokio` in transitively. **Contract.** @@ -275,7 +275,7 @@ env var. No CLI flags override the config-file path — callers set TORRUST_INDEX_CONFIG_TOML_PATH in the environment before invoking the probe, the same mechanism the application uses. -Refuses to run when stdout is a TTY (exit 2, per P8). +Refuses to run when stdout is a TTY (exit 2, per ADR-T-010). On success (exit 0), emits one JSON object + trailing newline on stdout: @@ -322,7 +322,7 @@ PEM material is *never* emitted, only its presence (`"pem_set": true`). The prob |------|---------| | 0 | Recognised, well-formed configuration. | | 1 | Unhandled panic or unexpected I/O on stdout. | -| 2 | Stdout is a TTY (P8), or clap argv-parse failure. | +| 2 | Stdout is a TTY (ADR-T-010), or clap argv-parse failure. | | 3 | Config-load failure (missing field, parse error, IO error). The underlying error message is forwarded verbatim to stderr via tracing. | | 4 | Security-critical field present but empty. Currently: `tracker.token`. | | 5 | Unrecognised database scheme. | @@ -733,16 +733,16 @@ The release-base symlink loop covers every applet the entry script invokes by ba ### D5 — Helper binaries as separate workspace crates -**Follows from:** P2, P8, P9. +**Follows from:** P2, P8, P9, and the global command-line output contract later extracted as ADR-T-010. **Addresses:** [R4](#r4--health_check-pulls-in-reqwest-for-a-localhost-get). -Every helper binary is extracted into its own workspace crate under `packages/index-*/` and follows P9's universal conventions. A shared `packages/index-cli-common/` library crate (`torrust-index-cli-common`) provides the scaffolding so each binary's `main` is only domain logic. +Every helper binary is extracted into its own workspace crate under `packages/index-*/` and follows the command-line output contract now defined globally by ADR-T-010. A shared `packages/index-cli-common/` library crate (`torrust-index-cli-common`) provides the scaffolding so each binary's `main` is only domain logic. The crate boundary makes the "no HTTP/TLS deps" property a manifest-level invariant: a future contributor cannot accidentally re-introduce `reqwest` because the crate's `Cargo.toml` simply does not list it. `reqwest` remains in the workspace for the importer and tracker clients; the goal is to prune it from the *helper binaries'* dep closures, not from the workspace. #### Helper crate roster -| Crate | Path | Domain deps (beyond P9 baseline) | +| Crate | Path | Domain deps (beyond ADR-T-010 helper baseline) | |---|---|---| | `torrust-index-cli-common` | `packages/index-cli-common/` | *(library — no binary)* | | `torrust-index-health-check` | `packages/index-health-check/` | *(none — stdlib networking)* | @@ -757,7 +757,7 @@ The dep-closure exclusion check ([Acceptance Criterion #5](#5-helper-binary-dep- **Public API:** ```rust -/// Refuse to run if stdout is a terminal (P8). +/// Refuse to run if stdout is a terminal (ADR-T-010). /// Prints a diagnostic to stderr and exits with code 2. pub fn refuse_if_stdout_is_tty(binary_name: &str); @@ -775,7 +775,7 @@ pub struct BaseArgs { } ``` -**Dependencies.** The P9 baseline and nothing else: `clap`, `tracing`, `tracing-subscriber` (with `json` feature), `serde`, `serde_json`. +**Dependencies.** The ADR-T-010 helper baseline and nothing else: `clap`, `tracing`, `tracing-subscriber` (with `json` feature), `serde`, `serde_json`. Every binary's `main` reduces to: @@ -795,7 +795,7 @@ fn main() -> std::process::ExitCode { Moved from `src/bin/health_check.rs` to `packages/index-health-check/`. Rewritten with `std::net::TcpStream` + minimal HTTP/1.1 GET (~30 lines), with `set_read_timeout` / `set_write_timeout` for a short connect/read window. No async runtime. -JSON stdout on success: +Stdout result JSON on success: ```json {"target": "http://localhost:3001/health_check", "status": 200, "elapsed_ms": 4} ``` @@ -806,7 +806,7 @@ On failure, stdout is empty; the exit code is the sole branch signal for callers Moved from `src/bin/generate_auth_keypair.rs` to `packages/index-auth-keypair/`. Domain dep is `rsa` (which re-exports `pkcs8`). -JSON stdout: +Stdout result JSON: ```json {"private_key_pem": "-----BEGIN PRIVATE KEY-----\n...", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n..."} ``` @@ -1119,9 +1119,9 @@ done exit 0 ``` -### 6. Helper JSON + TTY contract (P8, P9) +### 6. Helper JSON + TTY contract (ADR-T-010) -Every helper binary, when invoked with stdout attached to a TTY, exits with code 2 before producing any output. When invoked with stdout piped, every helper emits exactly one JSON object followed by one trailing newline on stdout, and `tracing` NDJSON events on stderr. +Every helper binary, when invoked with stdout attached to a TTY, exits with code 2 before producing any output. When invoked with stdout piped, every helper emits exactly one JSON object followed by one trailing newline on stdout, and `tracing` NDJSON events on stderr. This is the helper-binary acceptance slice of the global contract later extracted as ADR-T-010. ```sh set -eu @@ -1138,7 +1138,7 @@ for bin in torrust-index-health-check \ [ "$rc" -eq 2 ] || { echo "FAIL: $bin did not exit 2 on TTY (got $rc)" >&2; exit 1; } [ -z "$tty_out" ] || { echo "FAIL: $bin emitted output before TTY refusal" >&2; exit 1; } - # JSON stdout + # stdout result JSON case $bin in *health-check) out=$(docker run --rm --entrypoint="/usr/bin/$bin" \ @@ -1237,7 +1237,7 @@ Tracked for visibility; not part of this refactor: - `docker buildx` multi-platform builds (`linux/arm64`). - Image signing with `cosign`. - Pin base images (`gcr.io/distroless/cc-debian13` and `:debug`) by digest rather than tag for reproducible builds and supply-chain integrity. -- Reimplement the entry script's first-boot work as a small Rust binary (`torrust-index-entry`), eliminating vendored `su-exec` (privilege drop via direct `setgroups`/`setgid`/`setuid` syscalls), the shell-based IFS/heredoc parsing of probe output, and most of the curated busybox applet set. The `torrust-index-config` extraction, the P9 universal helper conventions, and the `torrust-index-config-probe` helper are deliberate stepping stones: they pull the parsing surface out of the root crate, establish the stderr-tracing / stdout-JSON contract all helpers share, and prove the script-↔-Rust integration shape before committing to the full rewrite. The entry binary would depend on `torrust-index-config` and `torrust-index-auth-keypair` directly, eliminating the serialisation boundary entirely. +- Reimplement the entry script's first-boot work as a small Rust binary (`torrust-index-entry`), eliminating vendored `su-exec` (privilege drop via direct `setgroups`/`setgid`/`setuid` syscalls), the shell-based IFS/heredoc parsing of probe output, and most of the curated busybox applet set. The `torrust-index-config` extraction, the ADR-T-010 helper conventions, and the `torrust-index-config-probe` helper are deliberate stepping stones: they pull the parsing surface out of the root crate, establish the stderr-tracing / stdout-JSON contract all helpers share, and prove the script-↔-Rust integration shape before committing to the full rewrite. The entry binary would depend on `torrust-index-config` and `torrust-index-auth-keypair` directly, eliminating the serialisation boundary entirely. - Promote `packages/render-text-as-image/` to a published crate and drop the root crate's `path = "packages/..."` override; once that lands, the directory can safely be added to `.containerignore`. --- diff --git a/adr/010-global-command-line-output-contract.md b/adr/010-global-command-line-output-contract.md new file mode 100644 index 00000000..abaac69d --- /dev/null +++ b/adr/010-global-command-line-output-contract.md @@ -0,0 +1,90 @@ +# ADR-T-010: Global Command-Line Output Contract + +**Status:** Decided +**Date:** 2026-05-13 +**Supersedes:** The output-stream rules from ADR-T-009 P8/P9. +**Relates to:** [ADR-T-009](009-container-infrastructure-refactor.md) (helper-binary extraction and dependency rules) +**Implementation plan:** [Command-Line Output Conformance Plan](../docs/plans/adr-010-command-line-output-conformance-plan.md) + +--- + +## Context + +ADR-T-009 introduced a strict stdout/stderr contract for container helper binaries: JSON results on stdout, JSON diagnostics on stderr, and no stdout result data directly to a terminal. That decision was made inside the container-infrastructure refactor because the entry script needed reliable JSON from small Rust helpers. + +The contract is not container-specific. The application has several first-party command-line entrypoints: the server binary, maintenance commands under `src/bin/`, container helpers under `packages/index-*/`, and future operator tools. If each command decides independently what stdout and stderr mean, shell integration becomes brittle and diagnostics can corrupt data streams. + +## Decision + +Adopt one repository-wide JSON-only command-line output contract for every first-party Torrust Index command-line entrypoint that is shipped, documented, or intended for operators. + +This includes: + +- the main `torrust-index` server binary; +- binaries under `src/bin/`; +- helper binaries under `packages/index-*/`; +- future entry, migration, maintenance, diagnostic, or operator tools. + +Tests, examples, benches, and one-off developer fixtures are outside the normative scope unless they are documented as application commands. + +### Streams + +Stdout and stderr are both machine-readable streams. A command writes JSON records to them or leaves them empty. Plain human-readable text is not a valid application output format on either stream. + +Stdout is reserved for command result data intended for a caller to consume. Diagnostics, logs, progress messages, warnings, help, usage, prompts, and status updates go to stderr as JSON control-plane records. + +A command that has no stdout result data should leave stdout empty. A long-running server process normally has no stdout result data; its diagnostics are logs and therefore belong on stderr. + +### JSON Output + +When a command emits stdout result data, the default wire format is exactly one JSON object followed by one trailing newline. + +On success (exit 0), a command that has stdout result data emits its JSON object on stdout and may emit JSON diagnostics on stderr. + +On failure (exit not 0), stdout is empty. The exit code is the branch signal for callers, and JSON diagnostics go to stderr. + +Commands that need a different stdout JSON shape, such as streaming output, must document that exception in the command's own contract and explain why the single-object JSON contract does not fit. When stdout result data is streaming, stdout uses NDJSON: one JSON object per line. Non-JSON stdout or stderr is outside this contract and requires a new ADR. + +### TTY Refusal + +A command that emits stdout result data refuses to run when stdout is attached to a terminal. It exits before producing stdout and reports the diagnostic as JSON on stderr. + +The refusal is unconditional for commands with stdout result data. It does not depend on whether the payload is sensitive, and it is not caused by the JSON encoding. JSON is the only output encoding for both streams; the TTY refusal exists because stdout result data is intended for another process or file. Operators who want to inspect output interactively can pipe it to another program such as `jq`, `less`, or `cat`. + +Commands that do not emit stdout result data do not refuse merely because stdout is attached to a terminal. They leave stdout empty and write any diagnostics to stderr as JSON. + +Exit code 2 is reserved for command-line usage failures, including TTY refusal and argv parsing errors produced by `clap`. + +### Diagnostics + +Operator-facing and script-facing commands use `tracing` for diagnostics. The diagnostic writer is stderr, configured with JSON output. + +Stderr is a JSON control and diagnostic stream. When stderr emits multiple records over time, it uses NDJSON: one JSON object per line. Diagnostic records should be `tracing` events so scripts can consume diagnostics without scraping text. Non-diagnostic control records, such as help and usage, also write JSON objects to stderr. Plain-text diagnostic formatting is not an output mode for first-party application binaries; operators can pipe JSON diagnostics to a viewer when they want a friendlier presentation. + +### Help And Usage Output + +Help and usage information is command output and follows the same JSON-only stream contract, but it is not stdout result data. + +A help request writes a JSON control-plane record to stderr and exits with code 0. It does not trigger stdout TTY refusal, because it does not emit stdout result data. + +A usage or argv-parse error writes a JSON diagnostic/control-plane record to stderr and exits with code 2. + +Rust commands may still use `clap` for argv parsing, but raw `clap` help or error text is a legacy gap unless it is wrapped in the JSON contract. + +Command-specific diagnostics should not use `println!` or `eprintln!` for progress, status, or errors; those would put raw text on stdout or stderr. + +## Implementation Guidance + +Use `torrust-index-cli-common` for command-line tools that emit a single JSON object on stdout. It provides the current shared scaffolding for the global contract: TTY refusal for commands with stdout result data, JSON tracing on stderr, JSON emission on stdout, and the common `--debug` flag. + +Commands that do not emit stdout result data still follow the stream separation rule: diagnostics and logs go to stderr as JSON, preferably through `tracing`. + +Existing commands that predate this ADR and print raw text to stdout or stderr are non-conforming legacy commands, not precedent. They should be migrated as follow-up work. Any functional change, operator-documentation change, or automation reuse of those commands must bring them under this ADR. + +## Consequences + +ADR-T-009 remains the historical record for why the helper binaries were extracted and why the first implementation exists. This ADR is the canonical application-wide output contract. + +New command-line entrypoints must state whether they emit stdout result data. If they do, they must either use the default single-object JSON contract or document a justified JSON exception. + +The main server and maintenance commands are governed by the same stdout/stderr separation as the helper binaries. The difference is only whether they have stdout result data. diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md new file mode 100644 index 00000000..30c5104c --- /dev/null +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -0,0 +1,472 @@ +# Command-Line Output Conformance Plan + +**Status:** Draft plan +**Date:** 2026-05-13 +**Implements:** [ADR-T-010](../../adr/010-global-command-line-output-contract.md) +**Related:** [ADR-T-009](../../adr/009-container-infrastructure-refactor.md) + +This is an implementation plan for ADR-T-010, not a separate ADR. Its job is to +turn the decided repository-wide command-line output contract into concrete +code, documentation, and regression tests. + +## Goal + +Bring every shipped, documented, or operator-facing first-party command-line +entrypoint into conformance with ADR-T-010. After this work, command stdout and +stderr are machine-readable streams: stdout is empty unless the command emits +result data, result data is JSON, diagnostics are JSON records on stderr, and +stdout-producing commands refuse to write result data directly to a terminal. + +## Scope + +In scope: + +- `src/main.rs` (`torrust-index` server binary). +- `src/bin/create_test_torrent.rs`. +- `src/bin/import_tracker_statistics.rs`. +- `src/bin/parse_torrent.rs`. +- `src/bin/seeder.rs`. +- `src/bin/upgrade.rs`. +- `packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs`. +- `packages/index-config-probe/src/bin/torrust-index-config-probe.rs`. +- `packages/index-health-check/src/bin/torrust-index-health-check.rs`. +- `share/container/entry_script_sh` and `share/container/entry_script_lib_sh`, + because the container entry script is a shipped first-party command + entrypoint. + +Command-reachable library paths that must also be cleaned up when they emit +operator-facing diagnostics: + +- `src/bootstrap/logging.rs`. +- `src/console/commands/seeder/app.rs`. +- `src/console/commands/seeder/logging.rs`. +- `src/console/commands/tracker_statistics_importer/app.rs`. +- `src/console/cronjobs/tracker_statistics_importer.rs`. +- `src/mailer.rs`. +- `src/tracker/statistics_importer.rs`. +- `src/upgrades/from_v1_0_0_to_v2_0_0/`. +- `src/utils/parse_torrent.rs`. +- `src/web/api/server/signals.rs`. + +Out of scope unless they become documented operator commands: + +- `build.rs` Cargo protocol output such as `cargo:rerun-if-changed=...`. +- Tests, benches, examples, and harnesses under `tests/`, `src/tests/`, and + package test directories. +- Developer-only scripts under `contrib/dev-tools/`. +- Library packages with no shipped binary entrypoint, such as Mudlark and + `render-text-as-image`. + +## Contract Baseline + +All migrated commands must share these baseline behaviours: + +- Exit code 0 means success. +- Exit code 1 means a runtime, startup, internal, or command execution failure + unless a command-specific contract documents a narrower non-usage code. +- Exit code 2 means command-line usage failure, including clap argv errors and + stdout TTY refusal. +- On failure, stdout is empty. +- Stderr is always JSON or empty. There is no TTY exemption for stderr; JSON + diagnostics remain JSON even when stderr is attached to a terminal. +- Commands with stdout result data refuse when stdout is attached to a terminal. + Commands with no stdout result data do not perform stdout TTY refusal. +- The in-scope stdout-producing commands use ADR-T-010's default single-object + stdout contract. None of them use the documented streaming NDJSON exception + unless a later command-specific contract explicitly says so. +- Help and version requests are JSON control-plane records on stderr. They do + not emit stdout result data and do not trigger stdout TTY refusal. +- Stderr records are NDJSON: one complete JSON object per line. Rust and shell + emitters must avoid partial writes that can interleave bytes from concurrent + records. +- Shared control-plane records, including help, version, usage errors, TTY + refusal, and panic diagnostics, include a schema/version field so scripts can + distinguish future contract revisions. +- Command-specific stdout result schemas should either include their own version + field or be documented as stable command contracts. + +## Redaction Policy + +JSON diagnostics are easier for operators and scripts to consume, but they also +make accidental secret exposure easier to automate. The migration must define +and apply a redaction policy before JSON stderr becomes the default path. + +Required redaction rules: + +- Never log raw database URLs that contain credentials. Either omit them or log + a redacted form with password, token, and query-secret components removed. +- Never log JWT secrets, private keys, admin tokens, session secrets, API keys, + SMTP passwords, or mailer credentials. +- Avoid putting secrets in error `Display` strings. Prefer typed error fields + that can be redacted before logging. +- Keep raw external utility stderr out of top-level diagnostic messages unless + it has been reviewed or wrapped as a field that can be redacted. +- Add focused tests or review guards for the most likely secret-bearing fields + before the rollout switches operator commands to JSON stderr by default. + +## Command Output Classification + +Commands with stdout result data: + +- `torrust-index-auth-keypair`: emits one JSON object containing the generated + key pair. +- `torrust-index-config-probe`: emits one JSON object containing the resolved + container-relevant configuration subset. +- `torrust-index-health-check`: emits one JSON object containing the health + result. +- `parse_torrent`: emits one JSON object containing the result schema version, + decoded torrent, original v1 info hash, and stable parse metadata such as + input byte length. Do not include raw filesystem paths in the stable result + schema unless the command contract also defines explicit path encoding rules. + +Commands with no stdout result data: + +- `torrust-index`: long-running server; stdout remains empty and logs go to + stderr as JSON. +- `create_test_torrent`: side-effect command; write the torrent file and emit + status diagnostics on stderr as JSON. If a future caller needs the generated + path as data, promote that to stdout result data and add TTY refusal at that + time. For this migration, it remains a no-stdout command. +- `import_tracker_statistics`: side-effect maintenance command; stdout remains + empty. +- `seeder`: side-effect load/seeding command; stdout remains empty. +- `upgrade`: side-effect migration command; stdout remains empty. +- `share/container/entry_script_sh`: orchestration entrypoint; stdout remains + empty except for stdout captured from helper binaries inside command + substitutions. + +## Shared Rust CLI Infrastructure + +Update `packages/index-cli-common` so every Rust binary can share the same +contract implementation instead of open-coding it. + +Required changes: + +- Define the shared JSON control-plane record shape, including a schema/version + field, command name, record kind, message, and structured fields for usage + errors, TTY refusal, and panic diagnostics. +- Add a JSON stderr record helper for control-plane output that does not depend + on a tracing subscriber already being installed. This is needed for clap help, + clap parse errors, early startup failures, and panic hooks. +- Add a `parse_args_or_exit::()` helper around `clap::Parser::try_parse()`: + help and version requests emit JSON control records to stderr and exit 0; + argv errors emit JSON diagnostic/control records to stderr and exit 2; + stdout remains empty. +- Replace all raw clap help/error paths in binaries with the shared parse + helper. +- Keep `emit()` as the single-object stdout JSON writer, but ensure all callers + use it only after TTY refusal. +- Keep TTY refusal exit code 2 for stdout-producing commands, and make the + refusal diagnostic a JSON stderr record. +- Add an `install_json_panic_hook(command_name)` helper. The hook must not call + Rust's default panic hook, because the default hook writes plain text to + stderr. It should emit one JSON diagnostic record to stderr and terminate with + exit code 1. +- Make the panic hook safe for non-main-thread panics: emit a best-effort JSON + diagnostic once, avoid waiting on other application threads, and terminate the + process without returning to the default panic path. +- Make JSON tracing setup write to stderr explicitly and use an idempotent + initialization path (`try_init` or an equivalent guard) so early startup and + later application setup cannot double-install a subscriber. +- Ensure each tracing event is emitted as one complete JSON line on stderr, even + when multiple tasks or threads log concurrently. Use a writer strategy that + serializes each completed record, such as a locked writer per event or an + equivalent non-interleaving writer, rather than relying on ad-hoc writes to a + shared stream. +- Add small runner helpers for the two command classes: stdout-producing + single-object commands, and no-stdout side-effect commands. +- Centralize direct `std::process::exit` usage in this shared infrastructure + where practical, so binaries return `ExitCode` from their own `main` function. + +## Root Crate Wiring + +Update the root package so the application binaries can use the shared CLI +contract crate. + +Required changes: + +- Add `torrust-index-cli-common` as a root dependency in `Cargo.toml`. +- Remove `text-colorizer` from root runtime code once terminal color output is + gone. Keep it only if a test-only or non-command path still needs it. +- Remove color formatting from command-reachable modules, including + `src/console/cronjobs/tracker_statistics_importer.rs`, + `src/tracker/statistics_importer.rs`, and + `src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs`. +- Ensure root binaries return `std::process::ExitCode` rather than `Result` from + `main`, so Rust's `Termination` implementation cannot print raw `Error: ...` + text to stderr. +- Convert startup functions that currently panic or unwrap at the binary + boundary into `Result`-returning functions whose errors are logged as JSON and + mapped to process exit codes. + +## Application Logging + +Update `src/bootstrap/logging.rs` and any command-specific logging modules so +all operator-facing diagnostics use JSON tracing on stderr. + +Required changes: + +- Replace the default, pretty, and compact command-line logging styles with JSON + stderr logging for application binaries. If human-formatted test logs are + still useful, keep them behind test-only helpers outside the operator command + path. +- Make `src/console/commands/seeder/logging.rs` delegate to the central JSON + stderr setup or remove the module. +- Initialize logging before any code path can emit diagnostics. +- Preserve the current operator intent of `--debug` and add `RUST_LOG` as the + more expressive override: when `RUST_LOG` is set and non-empty, use it as the + filter directive; otherwise, `--debug` raises the command's default filter to + debug; otherwise, use the command or server configuration default. Document + this precedence and make all paths produce JSON stderr records. +- Avoid emitting ANSI color escape sequences inside log messages. Prefer + structured fields such as `torrent_id`, `tracker_url`, `limit`, and + `elapsed_ms`. +- Apply the redaction policy to tracing fields and error messages before they + are serialized. + +## Main Server Binary + +Update `src/main.rs` and the startup path used by `torrust-index`. + +Required changes: + +- Install the JSON panic hook at process start. +- Initialize JSON stderr diagnostics before configuration loading can fail. +- Add a non-panicking configuration loader, for example + `try_initialize_configuration()`, and have `main` log loader failures as JSON + before returning a non-zero exit code. +- Change `app::run` to return `Result` or otherwise + convert startup failures into JSON diagnostics rather than `expect`/`unwrap` + panics. +- Replace `assert!` and `expect` at the binary boundary with JSON diagnostics + and explicit exit codes. +- Replace `println!` in `src/web/api/server/signals.rs` with a tracing event + that records the received shutdown signal or phase on JSON stderr. +- Replace raw output and process exits in `src/mailer.rs` with structured errors + or tracing errors that flow to the binary boundary. +- Remove raw output from `src/utils/parse_torrent.rs`; library parsing helpers + should return errors and let command callers decide how to report them. + +## Helper Binaries + +Update the helper binaries under `packages/index-*`. + +Required changes: + +- Use the shared JSON clap parser in: + `packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs`, + `packages/index-config-probe/src/bin/torrust-index-config-probe.rs`, and + `packages/index-health-check/src/bin/torrust-index-health-check.rs`. +- Install the shared JSON panic hook in each helper. +- Replace `torrust-index-config-probe`'s current panic hook, which preserves the + default raw panic output, with the shared JSON hook. +- Keep stdout success output as exactly one JSON object plus one trailing + newline. +- Keep failure stdout empty. +- Keep TTY refusal for all three helpers because they emit stdout result data. + +## Root `src/bin` Binaries + +Update every root maintenance and diagnostic binary. + +Required changes for `src/bin/parse_torrent.rs`: + +- Replace hand-rolled `std::env::args()` parsing with clap plus the shared JSON + clap wrapper. +- Install JSON tracing and the JSON panic hook. +- Refuse stdout TTY because this command emits stdout result data. +- Remove progress `println!` calls. +- Emit one JSON object on stdout on success, containing the result schema + version, decoded torrent, original v1 info hash, and input byte length. +- On invalid bencode, invalid torrent data, I/O errors, or serialization errors, + leave stdout empty, emit JSON diagnostics on stderr, and exit non-zero. + +Required changes for `src/bin/create_test_torrent.rs`: + +- Replace hand-rolled argv parsing with clap plus the shared JSON clap wrapper. +- Install JSON tracing and the JSON panic hook. +- Keep stdout empty. +- Replace usage `eprintln!`, `panic!`, and any future status output with JSON + diagnostics on stderr. +- Return explicit exit codes for invalid arguments, encode errors, file creation + errors, and write errors. +- Log the generated torrent path as a JSON stderr status record if operators + need confirmation. + +Required changes for `src/bin/import_tracker_statistics.rs` and its reachable +modules: + +- Add clap parsing with the shared JSON clap wrapper, even though the command + currently takes no arguments, so `--help` and unknown flags are JSON. +- Install JSON tracing and the JSON panic hook in the binary entrypoint. +- Keep stdout empty. +- Replace `println!`, `eprintln!`, colored strings, `expect`, and raw parse + failures in these paths with tracing events and `Result` propagation: + `src/console/commands/tracker_statistics_importer/app.rs`, + `src/console/cronjobs/tracker_statistics_importer.rs`, and + `src/tracker/statistics_importer.rs`. +- Convert database connection and import failures into JSON diagnostics and + explicit exit codes. + +Required changes for `src/bin/seeder.rs` and +`src/console/commands/seeder/app.rs`: + +- Install JSON tracing and the JSON panic hook in the binary entrypoint. +- Use the shared JSON clap wrapper for help and argv errors. +- Keep stdout empty. +- Replace the remaining `print!` error path with a structured tracing error event. +- Remove terminal color formatting from log messages. +- Replace `expect`, `unwrap`, and `panic!` in the command path with propagated + errors that the binary logs as JSON. +- Return `ExitCode` from `main` instead of `Result`. + +Required changes for `src/bin/upgrade.rs` and the v1-to-v2 upgrade modules: + +- Replace hand-rolled argv parsing with clap plus the shared JSON clap wrapper. +- Install JSON tracing and the JSON panic hook in the binary entrypoint. +- Keep stdout empty. +- Replace all `println!` and `eprintln!` calls under + `src/upgrades/from_v1_0_0_to_v2_0_0/` with tracing events. +- Remove terminal color formatting from diagnostics. +- Convert database open, migration, truncation, transfer, and file-read failures + into propagated errors that the binary logs as JSON. +- Replace `unwrap`, `expect`, and assertion failures in the command path with + typed errors where practical. For invariant violations that remain panics, the + JSON panic hook must still prevent raw stderr output. + +## Container Entry Script + +Update `share/container/entry_script_sh` and `share/container/entry_script_lib_sh`. + +Required changes: + +- Add POSIX-shell JSON diagnostic helpers, for example `json_log` and + `json_error_exit`, that write one JSON object per line to stderr. Because the + runtime image already ships `jq`, prefer `jq -cn --arg ...` for string escaping + rather than hand-built JSON. +- Check for `jq` before any helper depends on it. If `jq` is missing or cannot + run, emit one fixed, minimal JSON diagnostic to stderr without interpolating + untrusted values, then exit non-zero. +- Include the shared schema/version field in shell control-plane records. +- Replace every `echo ... >&2` diagnostic with the JSON helper. +- Replace informational `echo`/`printf` diagnostics in helper functions with JSON + stderr records. File writes to `/etc/motd` and `/etc/profile` are not stream + output and can remain plain text. +- Remove `set -x` under `DEBUG=1`. Replace it with explicit JSON debug records + at phase boundaries that are useful to operators. +- Add failure handling around external utilities that may emit raw stderr on + expected operator errors (`jq`, `addgroup`, `adduser`, `install`, `chown`, + `chmod`, `mkdir`, `rm`, and `su-exec`). Capture their stderr where practical, + redact it when needed, and re-emit it as JSON fields. +- Add a trap for unexpected shell failures that emits a JSON stderr diagnostic + with the failing line or phase before exiting non-zero. +- Keep helper stdout captured only in command substitutions. Do not forward + helper stdout directly to the terminal. +- Where pipeline status matters, split commands or capture statuses explicitly + instead of depending on non-POSIX shell features. +- Update `packages/index-entry-script` tests so validation failures assert JSON + stderr records rather than plain text. + +## Documentation Updates + +Update operator documentation after the behavior changes. + +Required changes: + +- Update `README.md` command examples that currently imply human-readable output. +- Update `docs/containers.md` for JSON stderr diagnostics, stdout result data, + helper TTY refusal, and recommended inspection patterns such as piping stdout + result data to `jq`. +- Update `upgrades/from_v1_0_0_to_v2_0_0/README.md` so upgrade examples describe + JSON stderr diagnostics and empty stdout. +- Update command module docs that currently show plain text output, especially + tracker statistics importer and upgrade docs. +- Add a `CHANGELOG.md` entry describing the operator-visible CLI output contract + change, marked as breaking for scripts that consumed the previous plain-text + command output. + +## Tests And Guards + +Add focused conformance tests near the command code and one broad guard to catch +future regressions. + +Required tests: + +- `packages/index-cli-common` tests for JSON help records, JSON version records, + JSON argv-error records, exit code mapping, TTY-refusal records, stdout JSON + emission, and the panic hook's JSON shape. +- Shared infrastructure tests for schema/version fields on control-plane + records, redaction of common secret-bearing fields, `RUST_LOG`/`--debug` + precedence, and concurrent JSON logging. The concurrency test should emit + records from multiple threads or tasks and assert that every captured stderr + line round-trips as one complete JSON object. +- Helper binary contract tests for success stdout shape, empty stdout on + failure, JSON stderr diagnostics, clap help JSON, clap version JSON, clap error + JSON, and exit code 2 for usage failures. +- Root binary contract tests for `parse_torrent` success/failure stdout shape, + and for no-stdout commands keeping stdout empty while logging JSON stderr. +- Container entry-script tests in `packages/index-entry-script` for JSON stderr + on each validation failure branch. +- Workspace clippy guards for raw stream output in shipped command paths. Prefer + `clippy::print_stdout`, `clippy::print_stderr`, and `clippy.toml` + `disallowed-macros` entries for `println!`, `eprintln!`, `print!`, and + `eprint!`, with explicit allow-list entries or local `#[allow]` annotations + for Cargo build-script protocol output and tests. +- A regression test for `main() -> Result` in in-scope binaries, because Rust's + default `Result` termination writes raw text on failure. +- A lint-backed guard for `std::process::exit` outside shared CLI infrastructure + and shell entry scripts. Prefer `clippy::exit` or a `clippy.toml` + `disallowed-methods` entry when supported, with explicit allow-list entries + for the shared CLI infrastructure and shell entry scripts. +- TTY-refusal smoke tests for stdout-producing commands. Use a pseudo-terminal + library or tool such as `rexpect` or `portable-pty` if in-process Rust tests + cannot reliably allocate a TTY. + +Suggested verification commands: + +```sh +cargo fmt --all +cargo check --workspace --all-targets --all-features 2>&1 | tee /tmp/adr010-cargo-check.log +cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tee /tmp/adr010-cargo-clippy.log +cargo test --workspace --all-targets --all-features 2>&1 | tee /tmp/adr010-cargo-test.log +cargo test --workspace --all-targets --all-features --release 2>&1 | tee /tmp/adr010-cargo-test-release.log +cargo check --workspace --all-targets --no-default-features 2>&1 | tee /tmp/adr010-cargo-check-no-default-features.log +cargo test --workspace --all-targets --no-default-features 2>&1 | tee /tmp/adr010-cargo-test-no-default-features.log +cargo test --doc --workspace --all-features 2>&1 | tee /tmp/adr010-cargo-test-doc.log +cargo doc --workspace --all-features --no-deps 2>&1 | tee /tmp/adr010-cargo-doc.log +``` + +After each command completes, grep the temp log for failures or warnings before +summarizing results, following the repository test-running convention. + +## Rollout Order + +1. Finalize the shared control-plane record shape, command-specific result + schema details, exit-code mapping, and redaction rules. +2. Extend `torrust-index-cli-common` with JSON clap handling, JSON panic hooks, + idempotent JSON stderr tracing, redaction helpers, and command runners. +3. Migrate the three helper binaries to the expanded shared infrastructure. +4. Switch central application logging to JSON stderr and make root binaries use + `ExitCode` boundaries. +5. Migrate `parse_torrent` and `create_test_torrent`. +6. Migrate `import_tracker_statistics`, `seeder`, and `upgrade`, including their + command-reachable modules. +7. Remove raw output from shared libraries reached by command paths. +8. Migrate the container entry script and its tests. +9. Update documentation and changelog. +10. Add regression guards and run the verification suite. + +## Open Decisions + +- The exact JSON field names for clap help, version, usage, TTY refusal, panic, + and status records. The shared records should be stable enough for scripts to + consume but small enough that command help text can evolve. +- Whether every stdout-producing command should include a command-specific + result schema version immediately, or whether that is introduced only when the + command result is documented for external automation. +- The exact exit-code taxonomy for root maintenance commands beyond the baseline + 0, 1, and ADR-T-010 reserved code 2. Existing helper-specific exit codes should + remain stable unless a command-specific contract says otherwise. +- How strict the container entry script can be with external utility stderr. Full + conformance requires expected failures to be captured and re-emitted as JSON; + unexpected process crashes may still need a pragmatic trap-based fallback. diff --git a/packages/index-auth-keypair/src/lib.rs b/packages/index-auth-keypair/src/lib.rs index f5eda4d4..133a84d4 100644 --- a/packages/index-auth-keypair/src/lib.rs +++ b/packages/index-auth-keypair/src/lib.rs @@ -1,7 +1,7 @@ //! RSA-2048 key pair generator for Torrust Index JWT authentication. //! //! Outputs a JSON object with `private_key_pem` and `public_key_pem` -//! fields to stdout (P9). Diagnostics go to stderr via `tracing`. +//! fields to stdout per ADR-T-010. Diagnostics go to stderr via JSON `tracing`. use rsa::RsaPrivateKey; use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; diff --git a/packages/index-cli-common/src/lib.rs b/packages/index-cli-common/src/lib.rs index e02dbf6f..633838de 100644 --- a/packages/index-cli-common/src/lib.rs +++ b/packages/index-cli-common/src/lib.rs @@ -1,7 +1,7 @@ -//! Shared CLI scaffolding for Torrust Index helper binaries (P9). +//! Shared CLI scaffolding for Torrust Index command-line tools (ADR-T-010). //! //! Every helper binary uses this crate for: -//! - TTY refusal (P8) +//! - TTY refusal for stdout result data //! - JSON tracing initialisation on stderr //! - JSON output on stdout @@ -10,9 +10,9 @@ use std::io::{self, IsTerminal, Write}; #[cfg(test)] mod tests; -/// Refuse to run if stdout is a terminal (P8). +/// Refuse to run if stdout is a terminal (ADR-T-010). /// -/// Emits a `tracing::error!` event (NDJSON on stderr, per P9) +/// Emits a `tracing::error!` event (NDJSON on stderr, per ADR-T-010) /// and exits with code 2. Call this **after** [`init_json_tracing`] /// so the diagnostic is structured rather than a bare `eprintln!`. pub fn refuse_if_stdout_is_tty(binary_name: &str) { diff --git a/packages/index-cli-common/tests/public_api.rs b/packages/index-cli-common/tests/public_api.rs index f7bae95f..e21a86de 100644 --- a/packages/index-cli-common/tests/public_api.rs +++ b/packages/index-cli-common/tests/public_api.rs @@ -62,7 +62,7 @@ fn base_args_rejects_unknown_short_flag() { // Pins clap's contract that unknown flags surface as a // parse error rather than being silently dropped — every // helper binary depends on this for the `unknown-flag → - // exit 2` mapping documented in ADR-T-009 §6.1. + // exit 2` mapping documented in ADR-T-010. let Err(err) = FixtureCli::try_parse_from(["fixture-helper", "--no-such-flag"]) else { panic!("unknown flag must be rejected by clap"); }; diff --git a/packages/index-config/src/lib.rs b/packages/index-config/src/lib.rs index ae7fbc49..46e7b63e 100644 --- a/packages/index-config/src/lib.rs +++ b/packages/index-config/src/lib.rs @@ -207,7 +207,7 @@ impl Info { // tokens, SMTP passwords, …) so log only the env-var name // — never its value — and route through `tracing` (stderr) // so we don't pollute the JSON-only stdout contract used - // by helper binaries (P9). + // by helper binaries (ADR-T-010). tracing::info!( env_var = ENV_VAR_CONFIG_TOML, "loading extra configuration from environment variable" @@ -229,7 +229,7 @@ impl Info { /// Build [`Info`] from the same env vars [`Self::new`] reads, /// without the diagnostic `println!`s. /// - /// Helper binaries that own a JSON-only stdout contract (P9) + /// Helper binaries that own a JSON-only stdout contract (ADR-T-010) /// must use this constructor instead of [`Self::new`] to avoid /// corrupting their output stream. #[must_use] diff --git a/packages/index-health-check/src/bin/torrust-index-health-check.rs b/packages/index-health-check/src/bin/torrust-index-health-check.rs index fbaba35d..9ca2643c 100644 --- a/packages/index-health-check/src/bin/torrust-index-health-check.rs +++ b/packages/index-health-check/src/bin/torrust-index-health-check.rs @@ -1,7 +1,7 @@ //! Minimal health-check binary for Torrust Index containers. //! -//! On success (exit 0), emits a JSON object to stdout per P9. -//! On failure (exit ≠ 0), stdout is empty; diagnostics go to stderr. +//! On success (exit 0), emits a JSON object to stdout per ADR-T-010. +//! On failure (exit ≠ 0), stdout is empty; diagnostics go to stderr as JSON. use std::process::ExitCode; diff --git a/src/jwt.rs b/src/jwt.rs index 970443d8..b5c51e1d 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -37,8 +37,9 @@ //! (`torrust-index-auth-keypair`) generates an RSA-2048 key pair //! and writes a JSON object with both PEM blocks to stdout. The container //! entry script uses it to auto-generate persistent keys on first boot. -//! See `packages/index-auth-keypair/src/main.rs` for the binary and -//! ADR-T-007 Phase 6 / ADR-T-009 Phase 2 for full context. +//! See `packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs` +//! for the binary and ADR-T-007 Phase 6 / ADR-T-009 Phase 2 / ADR-T-010 +//! for full context. //! //! **Phase 7 — Consolidated session validation.** `validate_session` //! is the sole entry point for session-token validation: it verifies From 4bbb32cf1fc0753738709c36fd37eb0c176121f2 Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 13 May 2026 21:42:57 +0200 Subject: [PATCH 02/13] feat(cli)!: implement ADR-T-010 stage-one output contract Add shared CLI output primitives to torrust-index-cli-common, including control-plane record types, baseline exit-code classes, and diagnostic redaction helpers for sensitive fields and database URLs. Version helper stdout result schemas with top-level schema fields for the auth-keypair, config-probe, and health-check contracts, and add coverage for the new shared records and schema-bearing helper outputs. Document the ADR-T-010 migration state for operators, including the helper JSON stdout contract, TTY-refusal expectations, and legacy root maintenance commands that still need follow-up migration. BREAKING CHANGE: helper stdout JSON schemas now include top-level schema fields, and first-party command-line output is governed by the ADR-T-010 JSON-only stdout/stderr contract. --- CHANGELOG.md | 25 ++ Cargo.lock | 1 + README.md | 26 ++ docs/containers.md | 38 ++- ...10-command-line-output-conformance-plan.md | 62 +++- .../src/bin/torrust-index-auth-keypair.rs | 3 + packages/index-auth-keypair/src/lib.rs | 7 +- packages/index-auth-keypair/src/tests/mod.rs | 8 + .../tests/keypair_generation.rs | 10 +- packages/index-cli-common/Cargo.toml | 1 + packages/index-cli-common/src/lib.rs | 295 ++++++++++++++++++ packages/index-cli-common/src/tests/mod.rs | 104 +++++- .../src/bin/torrust-index-config-probe.rs | 2 + .../src/bin/torrust-index-health-check.rs | 4 +- packages/index-health-check/src/lib.rs | 5 + packages/index-health-check/src/tests/mod.rs | 11 + .../index-health-check/tests/health_check.rs | 3 +- src/bin/create_test_torrent.rs | 4 +- src/bin/import_tracker_statistics.rs | 6 +- src/bin/parse_torrent.rs | 5 +- src/bin/seeder.rs | 4 + src/bin/upgrade.rs | 6 +- src/console/commands/seeder/app.rs | 5 + .../tracker_statistics_importer/app.rs | 11 +- src/lib.rs | 8 + .../from_v1_0_0_to_v2_0_0/upgrader.rs | 5 + upgrades/from_v1_0_0_to_v2_0_0/README.md | 11 + 27 files changed, 644 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc2419c..17b376c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ error system (ADR-T-006), MSRV raised to 1.88. ### Breaking changes - MSRV raised from 1.85 to 1.88. +- First-party command-line entrypoints are now governed by ADR-T-010's + JSON-only output contract. Stdout is reserved for machine-readable result + data, stderr is reserved for machine-readable diagnostics/control records, and + stdout-producing commands refuse direct terminal stdout. Existing plain-text + root maintenance commands are legacy gaps and will be migrated in later + stages. +- `torrust-index-auth-keypair` and `torrust-index-health-check` stdout JSON now + includes a top-level `schema` field. Scripts that expected an exact object + shape must tolerate or consume the schema field. - `database.connect_url` and `tracker.token` are now mandatory schema fields with no defaults. Supply them via env-var override (`TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL`, @@ -64,6 +73,22 @@ error system (ADR-T-006), MSRV raised to 1.88. - ADR-T-010, extracting ADR-T-009's helper stdout/stderr convention into a global command-line output contract for the application. +- Shared stage-1 command-line contract primitives in + `torrust-index-cli-common`: control-plane record schema, baseline exit-code + classes, structured help/version/usage/TTY-refusal/panic record types, and + diagnostic redaction helpers. + +#### Changed + +- Helper stdout result schemas are explicitly versioned with top-level + `schema` fields. `torrust-index-auth-keypair` emits `schema`, + `private_key_pem`, and `public_key_pem`; `torrust-index-config-probe` emits + `schema`, `database`, and `auth`; `torrust-index-health-check` emits + `schema`, `target`, `status`, and `elapsed_ms`. +- Operator documentation now describes the ADR-T-010 migration state: helper + binaries have the JSON stdout contract, while root maintenance binaries and + the container entry script remain legacy output gaps until their rollout + stages land. ### ADR-T-009 — Container infrastructure refactor diff --git a/Cargo.lock b/Cargo.lock index 1e49e259..3586144b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4256,6 +4256,7 @@ dependencies = [ "serde_json", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/README.md b/README.md index cc5be155..2b6c936e 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,32 @@ The following services are provided by the default configuration: - API - `http://127.0.0.1:3001/`. +### Command-Line Output + +First-party Torrust Index command-line entrypoints are governed by +[ADR-T-010](./adr/010-global-command-line-output-contract.md): stdout is +reserved for machine-readable result data, stderr is reserved for +machine-readable diagnostics/control records, and commands that emit stdout +result data refuse to write it directly to a terminal. + +The first implemented stage fixes the shared contract shape and the helper +result schemas used by the container runtime. These helpers emit exactly one +JSON object on stdout when successful, include a top-level `schema` field, and +should be inspected through a pipe or redirect: + +```sh +torrust-index-auth-keypair | jq . +torrust-index-config-probe | jq . +torrust-index-health-check http://127.0.0.1:3001/health_check | jq . +``` + +The older root maintenance binaries (`parse_torrent`, `create_test_torrent`, +`import_tracker_statistics`, `seeder`, and `upgrade`) are in ADR-T-010 scope but +are still migration targets. Do not build new automation around their current +plain-text output; the target contract for side-effect commands is empty stdout +and JSON diagnostics on stderr, while `parse_torrent` will become a JSON stdout +result command. + ## Documentation - [API (Version 1)][api] diff --git a/docs/containers.md b/docs/containers.md index cadefa54..8ab5a05a 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -405,6 +405,33 @@ failure rather than silent misbehaviour. ## Runtime Image Notes +### Command-Line Output Contract + +Container helper binaries follow the ADR-T-010 stdout/stderr split. When they +emit result data, stdout is exactly one JSON object with a trailing newline and +a top-level `schema` field. Diagnostics are emitted on stderr as JSON tracing +records where the helper has already been migrated. + +The stdout-producing helpers are: + +- `torrust-index-auth-keypair`: `schema`, `private_key_pem`, `public_key_pem`. +- `torrust-index-config-probe`: `schema`, `database`, `auth`. +- `torrust-index-health-check`: `schema`, `target`, `status`, `elapsed_ms`. + +These helpers refuse to write stdout result data directly to a terminal. Pipe or +redirect the result instead: + +```sh +torrust-index-auth-keypair | jq . +torrust-index-config-probe | jq . +torrust-index-health-check http://127.0.0.1:3001/health_check | jq . +``` + +The container entry script captures helper stdout internally and does not +forward it to the terminal. Its own diagnostics are still part of the ADR-T-010 +migration backlog; until that rollout stage lands, treat any plain-text entry +script stderr as a legacy compatibility gap rather than a new output contract. + ### Healthcheck (both targets) Both `release` and `debug` ship the same two-probe `HEALTHCHECK` @@ -480,6 +507,9 @@ immediately, and references to unset variables are treated as errors. This converts a class of silent-misconfiguration bugs into loud, actionable startup failures. +ADR-T-010 will replace this legacy shell tracing path with explicit JSON debug +records in a later rollout stage. Do not parse `set -x` output in automation. + ### Entry Script Contract Per ADR-T-009 §7, the entry script reads its configuration in @@ -516,8 +546,8 @@ above. — invoked as root after the default TOML is in place. The probe is the same loader the application uses, so it sees the operator's full TOML + env-var stack. Its JSON - output is consumed by `jq` and drives the remaining - steps. The probe runs *before* the script exports any + output (`schema`, `database`, `auth`) is consumed by `jq` + and drives the remaining steps. The probe runs *before* the script exports any `TORRUST_INDEX_CONFIG_OVERRIDE_*` of its own, so its output reflects only operator-supplied values. 5. **`TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__{PRIVATE,PUBLIC}_KEY_{PEM,PATH}`** @@ -581,6 +611,10 @@ the config probe's JSON output and the auth-keypair helper's JSON output. The unprivileged `torrust` user has no access to `/usr/bin/jq` after privilege drop. +Operators who run the helpers manually should use the same pattern: pipe stdout +result data to `jq`, redirect it to a file, or capture it from another process. +Direct terminal stdout is refused by design. + #### Sourced Shell Library The entry script's pure helper functions (`inst`, diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index 30c5104c..e98e47d4 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -85,6 +85,52 @@ All migrated commands must share these baseline behaviours: - Command-specific stdout result schemas should either include their own version field or be documented as stable command contracts. +## Stage 1 Contract Decisions + +The first implementation stage fixes the shared contract details that later +rollout stages wire into each binary. + +Shared stderr control-plane records use this top-level JSON shape: + +- `schema`: numeric shared control-plane record schema. The initial value is `1`. +- `command`: binary or entrypoint name. +- `kind`: one of `help`, `version`, `usage_error`, `tty_refusal`, `panic`, + `status`, or `diagnostic`. +- `message`: short human-readable message carried inside the JSON record. +- `fields`: optional kind-specific object tagged with `type`. + +The initial structured field variants are: + +- `help`: `text`. +- `version`: `version`. +- `usage_error`: `exit_code` and `clap_error_kind`. +- `tty_refusal`: `exit_code` and `stream`. +- `panic`: `exit_code`, `thread`, and `location`. Panic payloads are not part of + the shared record because they may contain secrets. + +The shared baseline exit-code classes are: + +- `success`: process status `0`. +- `failure`: process status `1`. +- `usage`: process status `2`. + +Command-specific non-usage exit codes may still be documented by the owning +command contract. For example, `torrust-index-config-probe` keeps its existing +configuration and probe failure codes until a command-specific contract changes +them. + +Stdout-producing command result schemas use a numeric top-level `schema` field. +The first-stage helper outputs are: + +- `torrust-index-auth-keypair`: `schema`, `private_key_pem`, and + `public_key_pem`. +- `torrust-index-config-probe`: `schema`, `database`, and `auth`. +- `torrust-index-health-check`: `schema`, `target`, `status`, and `elapsed_ms`. + +Shared redaction helpers apply the initial redaction policy for diagnostics: +secret-like field names are replaced with `[redacted]`, and database URLs have +userinfo plus secret-bearing query parameters removed before they are logged. + ## Redaction Policy JSON diagnostics are easier for operators and scripts to consume, but they also @@ -371,6 +417,12 @@ Required changes: Update operator documentation after the behavior changes. +Stage 1 documentation status: the shared contract shape, helper stdout result +schemas, and migration-status notes have been documented. Root maintenance +commands and the container entry script are still legacy output gaps until their +rollout stages land; their documentation should describe the ADR-T-010 target +contract without promising behaviour the binaries do not yet implement. + Required changes: - Update `README.md` command examples that currently imply human-readable output. @@ -458,15 +510,9 @@ summarizing results, following the repository test-running convention. ## Open Decisions -- The exact JSON field names for clap help, version, usage, TTY refusal, panic, - and status records. The shared records should be stable enough for scripts to - consume but small enough that command help text can evolve. -- Whether every stdout-producing command should include a command-specific - result schema version immediately, or whether that is introduced only when the - command result is documented for external automation. - The exact exit-code taxonomy for root maintenance commands beyond the baseline - 0, 1, and ADR-T-010 reserved code 2. Existing helper-specific exit codes should - remain stable unless a command-specific contract says otherwise. + `success`, `failure`, and `usage` classes. Existing helper-specific exit codes + should remain stable unless a command-specific contract says otherwise. - How strict the container entry script can be with external utility stderr. Full conformance requires expected failures to be captured and re-emitted as JSON; unexpected process crashes may still need a pragmatic trap-based fallback. diff --git a/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs b/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs index 31b41ef5..45647979 100644 --- a/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs +++ b/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs @@ -1,5 +1,8 @@ //! Generate an RSA-2048 key pair for JWT authentication. //! +//! Emits one JSON object with `schema`, `private_key_pem`, and `public_key_pem` +//! on stdout. Direct terminal stdout is refused; pipe or redirect the result. +//! //! # Usage //! //! ```sh diff --git a/packages/index-auth-keypair/src/lib.rs b/packages/index-auth-keypair/src/lib.rs index 133a84d4..b42a2271 100644 --- a/packages/index-auth-keypair/src/lib.rs +++ b/packages/index-auth-keypair/src/lib.rs @@ -1,6 +1,6 @@ //! RSA-2048 key pair generator for Torrust Index JWT authentication. //! -//! Outputs a JSON object with `private_key_pem` and `public_key_pem` +//! Outputs a JSON object with `schema`, `private_key_pem`, and `public_key_pem` //! fields to stdout per ADR-T-010. Diagnostics go to stderr via JSON `tracing`. use rsa::RsaPrivateKey; @@ -10,8 +10,12 @@ use serde::{Deserialize, Serialize}; #[cfg(test)] mod tests; +/// Schema version of the keypair JSON output. +pub const SCHEMA: u32 = 1; + #[derive(Serialize, Deserialize)] pub struct KeypairOutput { + pub schema: u32, pub private_key_pem: String, pub public_key_pem: String, } @@ -33,6 +37,7 @@ pub fn generate_keypair() -> Result { .map_err(|e| format!("public key PEM export failed: {e}"))?; Ok(KeypairOutput { + schema: SCHEMA, private_key_pem: private_pem.to_string(), public_key_pem: public_pem, }) diff --git a/packages/index-auth-keypair/src/tests/mod.rs b/packages/index-auth-keypair/src/tests/mod.rs index 95bf43d5..262bf367 100644 --- a/packages/index-auth-keypair/src/tests/mod.rs +++ b/packages/index-auth-keypair/src/tests/mod.rs @@ -3,6 +3,7 @@ //! | Test | What it covers | //! |---------------------------------------|-----------------------------------------| //! | `generated_json_round_trips` | JSON output deserialises back | +//! | `generated_output_carries_schema` | Output schema field is stable | //! | `private_pem_parses` | Private key PEM is valid PKCS#8 | //! | `public_pem_parses` | Public key PEM is valid SPKI | @@ -13,10 +14,17 @@ fn generated_json_round_trips() { let output = super::generate_keypair().unwrap(); let json = serde_json::to_string(&output).unwrap(); let parsed: super::KeypairOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.schema, super::SCHEMA); assert!(!parsed.private_key_pem.is_empty()); assert!(!parsed.public_key_pem.is_empty()); } +#[test] +fn generated_output_carries_schema() { + let output = super::generate_keypair().unwrap(); + assert_eq!(output.schema, super::SCHEMA); +} + #[test] fn private_pem_parses() { let output = super::generate_keypair().unwrap(); diff --git a/packages/index-auth-keypair/tests/keypair_generation.rs b/packages/index-auth-keypair/tests/keypair_generation.rs index d1ddeaf4..5f93bf7c 100644 --- a/packages/index-auth-keypair/tests/keypair_generation.rs +++ b/packages/index-auth-keypair/tests/keypair_generation.rs @@ -3,21 +3,29 @@ //! | Test | What it covers | //! |-----------------------------------------|-------------------------------------------| //! | `generated_keypair_round_trips_as_json` | JSON output deserialises back correctly | +//! | `generated_keypair_carries_schema` | JSON output schema field is stable | //! | `output_contains_valid_pem_keys` | PEM keys parse as RSA PKCS#8 / SPKI | //! | `successive_calls_produce_distinct_keys` | No hardcoded/cached key material | use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey}; -use torrust_index_auth_keypair::{KeypairOutput, generate_keypair}; +use torrust_index_auth_keypair::{KeypairOutput, SCHEMA, generate_keypair}; #[test] fn generated_keypair_round_trips_as_json() { let output = generate_keypair().unwrap(); let json = serde_json::to_string(&output).unwrap(); let parsed: KeypairOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.schema, SCHEMA); assert!(!parsed.private_key_pem.is_empty()); assert!(!parsed.public_key_pem.is_empty()); } +#[test] +fn generated_keypair_carries_schema() { + let output = generate_keypair().unwrap(); + assert_eq!(output.schema, SCHEMA); +} + #[test] fn output_contains_valid_pem_keys() { let output = generate_keypair().unwrap(); diff --git a/packages/index-cli-common/Cargo.toml b/packages/index-cli-common/Cargo.toml index ebe64ad5..4c8e516b 100644 --- a/packages/index-cli-common/Cargo.toml +++ b/packages/index-cli-common/Cargo.toml @@ -16,6 +16,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0" tracing-subscriber = { version = "0", features = ["json"] } +url = "2" [lints] workspace = true diff --git a/packages/index-cli-common/src/lib.rs b/packages/index-cli-common/src/lib.rs index 633838de..501c9911 100644 --- a/packages/index-cli-common/src/lib.rs +++ b/packages/index-cli-common/src/lib.rs @@ -5,11 +5,306 @@ //! - JSON tracing initialisation on stderr //! - JSON output on stdout +use std::borrow::Cow; use std::io::{self, IsTerminal, Write}; +use std::process::ExitCode; + +use serde::{Deserialize, Serialize}; #[cfg(test)] mod tests; +/// Schema version for shared ADR-T-010 control-plane records. +pub const CONTROL_PLANE_SCHEMA: u32 = 1; + +/// Placeholder used when a diagnostic value is intentionally hidden. +pub const REDACTED: &str = "[redacted]"; + +/// Baseline exit-code classes shared by ADR-T-010 command-line tools. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CommandExit { + /// Successful completion. + Success, + /// Runtime, startup, internal, or command execution failure. + Failure, + /// Command-line usage failure, including clap errors and TTY refusal. + Usage, +} + +impl CommandExit { + /// Return the numeric process status for this exit class. + #[must_use] + pub const fn code(self) -> u8 { + match self { + Self::Success => 0, + Self::Failure => 1, + Self::Usage => 2, + } + } + + /// Return this class as a standard library [`ExitCode`]. + #[must_use] + pub fn exit_code(self) -> ExitCode { + ExitCode::from(self.code()) + } + + /// Map a numeric status back to a shared exit class. + #[must_use] + pub const fn from_code(code: u8) -> Option { + match code { + 0 => Some(Self::Success), + 1 => Some(Self::Failure), + 2 => Some(Self::Usage), + _ => None, + } + } +} + +/// JSON record kinds emitted on stderr as ADR-T-010 control-plane data. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ControlPlaneRecordKind { + /// Help text requested by the caller. + Help, + /// Version information requested by the caller. + Version, + /// Command-line usage or argv parsing failure. + UsageError, + /// Refusal to write stdout result data directly to a terminal. + TtyRefusal, + /// Panic diagnostic emitted by the shared panic hook. + Panic, + /// Non-error status update. + Status, + /// Runtime diagnostic or error. + Diagnostic, +} + +/// Standard streams referenced by control-plane records. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StandardStream { + /// Standard output. + Stdout, + /// Standard error. + Stderr, +} + +/// Structured fields attached to a control-plane record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ControlPlaneFields { + /// Help text returned by clap or a command-specific renderer. + Help { text: String }, + /// Version string returned by clap or the command. + Version { version: String }, + /// Details for an argv parsing or usage error. + UsageError { exit_code: u8, clap_error_kind: String }, + /// Details for stdout TTY refusal. + TtyRefusal { exit_code: u8, stream: StandardStream }, + /// Details for a panic diagnostic. The panic payload is deliberately omitted. + Panic { + exit_code: u8, + thread: Option, + location: Option, + }, +} + +/// Shared JSON control-plane record written to stderr. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneRecord { + /// Record schema version. + pub schema: u32, + /// Binary or entrypoint name. + pub command: String, + /// Machine-readable record kind. + pub kind: ControlPlaneRecordKind, + /// Short human-readable message, still carried inside JSON. + pub message: String, + /// Kind-specific structured fields. + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option, +} + +impl ControlPlaneRecord { + /// Build a generic control-plane record. + #[must_use] + pub fn new(command: &str, kind: ControlPlaneRecordKind, message: &str, fields: Option) -> Self { + Self { + schema: CONTROL_PLANE_SCHEMA, + command: command.to_string(), + kind, + message: message.to_string(), + fields, + } + } + + /// Build a help record. + #[must_use] + pub fn help(command: &str, text: &str) -> Self { + Self::new( + command, + ControlPlaneRecordKind::Help, + "help requested", + Some(ControlPlaneFields::Help { text: text.to_string() }), + ) + } + + /// Build a version record. + #[must_use] + pub fn version(command: &str, version: &str) -> Self { + Self::new( + command, + ControlPlaneRecordKind::Version, + "version requested", + Some(ControlPlaneFields::Version { + version: version.to_string(), + }), + ) + } + + /// Build an argv usage-error record. + #[must_use] + pub fn usage_error(command: &str, message: &str, clap_error_kind: &str) -> Self { + Self::new( + command, + ControlPlaneRecordKind::UsageError, + message, + Some(ControlPlaneFields::UsageError { + exit_code: CommandExit::Usage.code(), + clap_error_kind: clap_error_kind.to_string(), + }), + ) + } + + /// Build a stdout TTY-refusal record. + #[must_use] + pub fn tty_refusal(command: &str) -> Self { + Self::new( + command, + ControlPlaneRecordKind::TtyRefusal, + "stdout is a terminal; pipe to a file or another process", + Some(ControlPlaneFields::TtyRefusal { + exit_code: CommandExit::Usage.code(), + stream: StandardStream::Stdout, + }), + ) + } + + /// Build a panic diagnostic record. + #[must_use] + pub fn panic(command: &str, thread: Option<&str>, location: Option<&str>) -> Self { + Self::new( + command, + ControlPlaneRecordKind::Panic, + "unexpected panic", + Some(ControlPlaneFields::Panic { + exit_code: CommandExit::Failure.code(), + thread: thread.map(str::to_string), + location: location.map(str::to_string), + }), + ) + } +} + +/// Return true when a diagnostic field name is likely to contain a secret. +#[must_use] +pub fn is_sensitive_field_name(field_name: &str) -> bool { + const SENSITIVE_MARKERS: &[&str] = &[ + "admin_token", + "api_key", + "apikey", + "credential", + "credentials", + "jwt_secret", + "mailer_password", + "passwd", + "password", + "private_key", + "pwd", + "secret", + "session_secret", + "smtp_password", + "token", + ]; + + let normalized = normalize_name(field_name); + SENSITIVE_MARKERS.iter().any(|marker| normalized.contains(marker)) +} + +/// Redact a diagnostic field value according to the ADR-T-010 policy. +#[must_use] +pub fn redact_field_value<'a>(field_name: &str, value: &'a str) -> Cow<'a, str> { + if is_sensitive_field_name(field_name) { + Cow::Borrowed(REDACTED) + } else { + redact_database_url(value) + } +} + +/// Redact credentials and secret query parameters from a database URL. +#[must_use] +pub fn redact_database_url(value: &str) -> Cow<'_, str> { + const DATABASE_SCHEMES: &[&str] = &["mariadb", "mysql", "postgres", "postgresql", "sqlite"]; + + let Ok(mut url) = url::Url::parse(value) else { + return Cow::Borrowed(value); + }; + + if !DATABASE_SCHEMES.contains(&url.scheme()) { + return Cow::Borrowed(value); + } + + let username_changed = !url.username().is_empty() && url.set_username("").is_ok(); + let password_changed = url.password().is_some() && url.set_password(None).is_ok(); + + let mut safe_query_pairs = Vec::new(); + let mut removed_query_pair = false; + for (key, query_value) in url.query_pairs() { + if is_sensitive_query_key(&key) { + removed_query_pair = true; + } else { + safe_query_pairs.push((key.into_owned(), query_value.into_owned())); + } + } + + if removed_query_pair { + url.set_query(None); + { + let mut query_pairs = url.query_pairs_mut(); + for (key, query_value) in safe_query_pairs { + query_pairs.append_pair(&key, &query_value); + } + } + } + + let changed = username_changed || password_changed || removed_query_pair; + + if changed { + Cow::Owned(url.to_string()) + } else { + Cow::Borrowed(value) + } +} + +fn normalize_name(name: &str) -> String { + let mut normalized = String::with_capacity(name.len()); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + } else { + normalized.push('_'); + } + } + normalized +} + +fn is_sensitive_query_key(key: &str) -> bool { + let normalized = normalize_name(key); + normalized == "key" || normalized.ends_with("_key") || is_sensitive_field_name(key) +} + /// Refuse to run if stdout is a terminal (ADR-T-010). /// /// Emits a `tracing::error!` event (NDJSON on stderr, per ADR-T-010) diff --git a/packages/index-cli-common/src/tests/mod.rs b/packages/index-cli-common/src/tests/mod.rs index a0074098..18f25a92 100644 --- a/packages/index-cli-common/src/tests/mod.rs +++ b/packages/index-cli-common/src/tests/mod.rs @@ -8,6 +8,14 @@ //! | `emit_to_real_stdout_succeeds` | `emit` itself writes to the captured stdout. | //! | `base_args_parses_default` | `BaseArgs::debug` defaults to `false`. | //! | `base_args_parses_long_flag` | `--debug` flips `BaseArgs::debug` to `true`. | +//! | `command_exit_codes_match_contract` | Baseline process statuses are fixed. | +//! | `control_record_serialises_shape` | Shared stderr record shape is stable. | +//! | `usage_error_record_carries_fields` | Usage records include exit code and clap kind. | +//! | `tty_refusal_record_carries_fields` | TTY refusal records identify stdout and code 2. | +//! | `panic_record_omits_payload` | Panic records avoid serialising panic payloads. | +//! | `redacts_sensitive_field_values` | Secret-like field names are hidden. | +//! | `redacts_database_url_secrets` | DB credentials and query secrets are removed. | +//! | `keeps_public_key_fields_visible` | Public key metadata is not treated as secret. | //! //! `refuse_if_stdout_is_tty` and `init_json_tracing` mutate //! global process state (the `process::exit` path and the @@ -22,8 +30,12 @@ use std::io::{self, Write}; use clap::Parser; use serde::Serialize; +use serde_json::json; -use crate::BaseArgs; +use crate::{ + BaseArgs, CONTROL_PLANE_SCHEMA, CommandExit, ControlPlaneFields, ControlPlaneRecord, ControlPlaneRecordKind, REDACTED, + StandardStream, redact_database_url, redact_field_value, +}; /// A `Write` that fails every call with `BrokenPipe`. /// @@ -131,3 +143,93 @@ fn base_args_parses_long_flag() { let parsed = Cli::try_parse_from(["prog", "--debug"]).expect("--debug is valid"); assert!(parsed.base.debug); } + +#[test] +fn command_exit_codes_match_contract() { + assert_eq!(CommandExit::Success.code(), 0); + assert_eq!(CommandExit::Failure.code(), 1); + assert_eq!(CommandExit::Usage.code(), 2); + + assert_eq!(CommandExit::from_code(0), Some(CommandExit::Success)); + assert_eq!(CommandExit::from_code(1), Some(CommandExit::Failure)); + assert_eq!(CommandExit::from_code(2), Some(CommandExit::Usage)); + assert_eq!(CommandExit::from_code(3), None); +} + +#[test] +fn control_record_serialises_shape() { + let record = ControlPlaneRecord::new("fixture", ControlPlaneRecordKind::Status, "ready", None); + let value = serde_json::to_value(record).unwrap(); + + assert_eq!(value["schema"], json!(CONTROL_PLANE_SCHEMA)); + assert_eq!(value["command"], json!("fixture")); + assert_eq!(value["kind"], json!("status")); + assert_eq!(value["message"], json!("ready")); + assert!(value.get("fields").is_none()); +} + +#[test] +fn usage_error_record_carries_fields() { + let record = ControlPlaneRecord::usage_error("fixture", "unknown argument", "unknown_argument"); + + assert_eq!(record.kind, ControlPlaneRecordKind::UsageError); + assert_eq!( + record.fields, + Some(ControlPlaneFields::UsageError { + exit_code: CommandExit::Usage.code(), + clap_error_kind: "unknown_argument".to_string(), + }) + ); + + let value = serde_json::to_value(record).unwrap(); + assert_eq!(value["fields"]["type"], json!("usage_error")); + assert_eq!(value["fields"]["exit_code"], json!(2)); +} + +#[test] +fn tty_refusal_record_carries_fields() { + let record = ControlPlaneRecord::tty_refusal("fixture"); + + assert_eq!(record.kind, ControlPlaneRecordKind::TtyRefusal); + assert_eq!( + record.fields, + Some(ControlPlaneFields::TtyRefusal { + exit_code: CommandExit::Usage.code(), + stream: StandardStream::Stdout, + }) + ); +} + +#[test] +fn panic_record_omits_payload() { + let record = ControlPlaneRecord::panic("fixture", Some("main"), Some("src/main.rs:12:34")); + let value = serde_json::to_value(record).unwrap(); + + assert_eq!(value["kind"], json!("panic")); + assert_eq!(value["fields"]["type"], json!("panic")); + assert_eq!(value["fields"]["exit_code"], json!(1)); + assert_eq!(value["fields"]["thread"], json!("main")); + assert!(value["fields"].get("payload").is_none()); +} + +#[test] +fn redacts_sensitive_field_values() { + assert_eq!(redact_field_value("tracker.token", "MyAccessToken"), REDACTED); + assert_eq!(redact_field_value("smtp_password", "secret"), REDACTED); + assert_eq!(redact_field_value("auth.private_key_pem", "PEM"), REDACTED); +} + +#[test] +fn redacts_database_url_secrets() { + let redacted = redact_database_url("mysql://user:pass@example.test/db?ssl-mode=required&token=abc&password=def"); + + assert_eq!(redacted, "mysql://example.test/db?ssl-mode=required"); +} + +#[test] +fn keeps_public_key_fields_visible() { + assert_eq!( + redact_field_value("auth.public_key_path", "/etc/torrust/public.pem"), + "/etc/torrust/public.pem" + ); +} diff --git a/packages/index-config-probe/src/bin/torrust-index-config-probe.rs b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs index cfadeced..5e4e9e6c 100644 --- a/packages/index-config-probe/src/bin/torrust-index-config-probe.rs +++ b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs @@ -1,5 +1,7 @@ //! Resolve the Torrust Index configuration and print the //! container-relevant subset as a single JSON object on stdout. +//! The JSON object contains `schema`, `database`, and `auth`. +//! Direct terminal stdout is refused; pipe or redirect the result. //! //! See ADR-T-009 §D3. diff --git a/packages/index-health-check/src/bin/torrust-index-health-check.rs b/packages/index-health-check/src/bin/torrust-index-health-check.rs index 9ca2643c..5873648d 100644 --- a/packages/index-health-check/src/bin/torrust-index-health-check.rs +++ b/packages/index-health-check/src/bin/torrust-index-health-check.rs @@ -1,7 +1,9 @@ //! Minimal health-check binary for Torrust Index containers. //! -//! On success (exit 0), emits a JSON object to stdout per ADR-T-010. +//! On success (exit 0), emits one JSON object with `schema`, `target`, `status`, +//! and `elapsed_ms` to stdout per ADR-T-010. //! On failure (exit ≠ 0), stdout is empty; diagnostics go to stderr as JSON. +//! Direct terminal stdout is refused; pipe or redirect the result. use std::process::ExitCode; diff --git a/packages/index-health-check/src/lib.rs b/packages/index-health-check/src/lib.rs index d5d20b62..58216bb4 100644 --- a/packages/index-health-check/src/lib.rs +++ b/packages/index-health-check/src/lib.rs @@ -13,8 +13,12 @@ use serde::Serialize; #[cfg(test)] mod tests; +/// Schema version of the health-check JSON output. +pub const SCHEMA: u32 = 1; + #[derive(Serialize)] pub struct HealthCheckOutput { + pub schema: u32, pub target: String, pub status: u16, pub elapsed_ms: u64, @@ -105,6 +109,7 @@ pub fn do_health_check(url: &str) -> Result } Ok(HealthCheckOutput { + schema: SCHEMA, target: url.to_string(), status, elapsed_ms, diff --git a/packages/index-health-check/src/tests/mod.rs b/packages/index-health-check/src/tests/mod.rs index 65e18f70..0e72f412 100644 --- a/packages/index-health-check/src/tests/mod.rs +++ b/packages/index-health-check/src/tests/mod.rs @@ -3,6 +3,7 @@ //! | Test | What it covers | //! |------------------------------------|---------------------------------------| //! | `success_on_200` | Happy path: 200 OK response | +//! | `success_output_carries_schema` | Output schema field is stable | //! | `failure_on_non_2xx` | Non-success HTTP status code | //! | `failure_on_connection_refused` | Target not listening | //! | `failure_on_read_timeout` | Server accepts but never responds | @@ -42,9 +43,19 @@ fn success_on_200() { let result = handle.join().unwrap(); assert!(result.is_ok()); let output = result.unwrap(); + assert_eq!(output.schema, super::SCHEMA); assert_eq!(output.status, 200); } +#[test] +fn success_output_carries_schema() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let output = handle.join().unwrap().unwrap(); + assert_eq!(output.schema, super::SCHEMA); +} + #[test] fn failure_on_non_2xx() { let (listener, url) = ephemeral_server(); diff --git a/packages/index-health-check/tests/health_check.rs b/packages/index-health-check/tests/health_check.rs index 47dae86d..d3099788 100644 --- a/packages/index-health-check/tests/health_check.rs +++ b/packages/index-health-check/tests/health_check.rs @@ -11,7 +11,7 @@ use std::io::{Read, Write}; use std::net::TcpListener; -use torrust_index_health_check::do_health_check; +use torrust_index_health_check::{SCHEMA, do_health_check}; /// Bind an ephemeral-port listener and return (listener, url). fn ephemeral_server() -> (TcpListener, String) { @@ -77,6 +77,7 @@ fn output_contains_expected_fields() { let output = handle.join().unwrap().unwrap(); let json = serde_json::to_value(&output).unwrap(); + assert_eq!(json["schema"], SCHEMA); assert!(json.get("target").is_some(), "missing 'target' field"); assert!(json.get("status").is_some(), "missing 'status' field"); assert!(json.get("elapsed_ms").is_some(), "missing 'elapsed_ms' field"); diff --git a/src/bin/create_test_torrent.rs b/src/bin/create_test_torrent.rs index 72612282..e0524ac5 100644 --- a/src/bin/create_test_torrent.rs +++ b/src/bin/create_test_torrent.rs @@ -1,6 +1,8 @@ //! Command line tool to create a test torrent file. //! -//! It's only used for debugging purposes. +//! It's only used for debugging purposes. ADR-T-010 classifies it as a +//! side-effect command: the target contract is empty stdout and JSON diagnostics +//! on stderr. The current implementation is a legacy output gap until migration. use std::env; use std::fs::File; use std::io::Write; diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index 07a0a0c6..bf3056c5 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -2,7 +2,11 @@ //! //! It imports the number of seeders and leechers for all torrents from the linked tracker. //! -//! You can execute it with: `cargo run --bin import_tracker_statistics` +//! You can execute it with: `cargo run --bin import_tracker_statistics`. +//! +//! ADR-T-010 classifies this as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until migration. use torrust_index::console::commands::tracker_statistics_importer::app::run; #[tokio::main] diff --git a/src/bin/parse_torrent.rs b/src/bin/parse_torrent.rs index 06c6423a..d2073aea 100644 --- a/src/bin/parse_torrent.rs +++ b/src/bin/parse_torrent.rs @@ -1,6 +1,9 @@ //! Command line tool to parse a torrent file and print the decoded torrent. //! -//! It's only used for debugging purposes. +//! It's only used for debugging purposes. ADR-T-010 classifies it as a stdout +//! result-data command; after migration it will emit one JSON object on stdout, +//! refuse direct terminal stdout, and report diagnostics on stderr as JSON. The +//! current implementation is a legacy output gap until migration. use std::env; use std::fs::File; use std::io::{self, Read}; diff --git a/src/bin/seeder.rs b/src/bin/seeder.rs index 01fc566f..f5cc93c7 100644 --- a/src/bin/seeder.rs +++ b/src/bin/seeder.rs @@ -1,4 +1,8 @@ //! Program to upload random torrents to a live Index API. +//! +//! ADR-T-010 classifies this as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until migration. use torrust_index::console::commands::seeder::app; #[tokio::main] diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index fd072f4f..aafa69d1 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -1,6 +1,10 @@ //! Upgrade command. //! It updates the application from version v1.0.0 to v2.0.0. -//! You can execute it with: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads` +//! You can execute it with: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads`. +//! +//! ADR-T-010 classifies this as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until migration. use torrust_index::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run; diff --git a/src/console/commands/seeder/app.rs b/src/console/commands/seeder/app.rs index 288547b9..f1e1a417 100644 --- a/src/console/commands/seeder/app.rs +++ b/src/console/commands/seeder/app.rs @@ -1,5 +1,10 @@ //! Console app to upload random torrents to a live Index API. //! +//! ADR-T-010 classifies this as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until the command is migrated, so do not parse its current +//! human-formatted logs in automation. +//! //! Run with: //! //! ```text diff --git a/src/console/commands/tracker_statistics_importer/app.rs b/src/console/commands/tracker_statistics_importer/app.rs index 4578837c..71847272 100644 --- a/src/console/commands/tracker_statistics_importer/app.rs +++ b/src/console/commands/tracker_statistics_importer/app.rs @@ -5,13 +5,10 @@ //! //! You can execute it with: `cargo run --bin import_tracker_statistics`. //! -//! After running it you will see the following output: -//! -//! ```text -//! Importing statistics from linked tracker ... -//! Loading configuration from config file `./config.toml` -//! Tracker url: udp://localhost:6969 -//! ``` +//! ADR-T-010 classifies this as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until the command is migrated, so do not parse its current +//! plain-text diagnostics in automation. //! //! Statistics are also imported: //! diff --git a/src/lib.rs b/src/lib.rs index c2287e20..87a7fd07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -232,6 +232,10 @@ //! ## Tracker Statistics Importer //! //! This console command allows you to manually import the tracker statistics. +//! ADR-T-010 classifies it as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until the command is migrated, so scripts should not parse +//! its plain-text diagnostics. //! //! For more information about this command you can visit the documentation for //! the [`Import tracker statistics`](crate::console::commands::tracker_statistics_importer) module. @@ -240,6 +244,10 @@ //! //! This console command allows you to manually upgrade the application from one //! version to another. +//! ADR-T-010 classifies it as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until the command is migrated, so scripts should branch on +//! exit status rather than parsing current plain text. //! //! For more information about this command you can visit the documentation for //! the [`Upgrade app from version 1.0.0 to 2.0.0`](crate::upgrades::from_v1_0_0_to_v2_0_0::upgrader) module. diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 51b58637..5984e650 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -18,6 +18,11 @@ //! cargo run --bin upgrade ./data.db ./data_v2.db ./uploads //! ``` //! +//! ADR-T-010 classifies this as a side-effect command: the target contract is +//! empty stdout and JSON diagnostics on stderr. The current implementation is a +//! legacy output gap until migration, so automation should branch on exit status +//! rather than parsing current plain text. +//! //! This command was created to help users to migrate from version `v1.0.0` to //! `v2.0.0`. The main changes in version `v2.0.0` were: //! diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index 37609149..f57a01b2 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -20,6 +20,17 @@ connect_url = "sqlite://data_v2.db?mode=rwc" - Perform some tests. - If all tests pass, stop the production service, replace the DB, and start it again. +## Command Output + +`upgrade` is an ADR-T-010 side-effect command: its target contract is empty +stdout, with status and error diagnostics emitted as JSON records on stderr. +Automation should branch on the process exit code and must not parse plain text +from stdout. + +The current binary is still a legacy output gap until its ADR-T-010 migration +stage lands. Treat any current plain-text diagnostics as temporary and avoid +building scripts around them. + ## Tests Before replacing the DB in production you can make some tests like: From c9faf166ecf12abc80492c958da7a508b8f0cfcd Mon Sep 17 00:00:00 2001 From: Cameron Garnham Date: Wed, 13 May 2026 22:45:32 +0200 Subject: [PATCH 03/13] feat(cli)!: implement ADR-T-010 helper control plane Extend torrust-index-cli-common with the shared helper infrastructure for JSON clap help/version/usage records, direct stderr control-plane emission, JSON-only panic diagnostics, RUST_LOG/--debug tracing precedence, locked stderr writes, and stdout/no-stdout command runners. Wire auth-keypair, config-probe, and health-check through the shared helpers so their help, version, argv errors, TTY refusal, and panic diagnostics are emitted as stderr control-plane JSON while stdout remains reserved for successful result JSON. Update ADRs, operator docs, and the changelog for the helper rollout, and expand cli-common coverage for parser wrapping, tracing filter resolution, and JSON line emission. BREAKING CHANGE: container helper help, version, argv errors, TTY refusal, and panic diagnostics now emit JSON control-plane records on stderr instead of legacy plain-text output paths. --- CHANGELOG.md | 10 + README.md | 9 +- adr/007-jwt-system-refactor.md | 8 +- adr/009-container-infrastructure-refactor.md | 65 ++- ...010-global-command-line-output-contract.md | 4 +- docs/containers.md | 10 +- ...10-command-line-output-conformance-plan.md | 46 +- .../src/bin/torrust-index-auth-keypair.rs | 113 +++-- packages/index-cli-common/Cargo.toml | 2 +- packages/index-cli-common/src/lib.rs | 420 +++++++++++++++++- packages/index-cli-common/src/tests/mod.rs | 115 ++++- packages/index-cli-common/tests/public_api.rs | 26 +- .../src/bin/torrust-index-config-probe.rs | 99 ++++- .../src/bin/torrust-index-health-check.rs | 108 +++-- src/jwt.rs | 5 +- 15 files changed, 909 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b376c9..9e4fa44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,11 @@ error system (ADR-T-006), MSRV raised to 1.88. `torrust-index-cli-common`: control-plane record schema, baseline exit-code classes, structured help/version/usage/TTY-refusal/panic record types, and diagnostic redaction helpers. +- Stage-2 shared CLI infrastructure in `torrust-index-cli-common`: JSON + `clap` help/version/usage wrapping, direct JSON stderr control-plane writes, + a JSON-only panic hook, idempotent JSON stderr tracing with `RUST_LOG` / + `--debug` precedence, a non-interleaving stderr writer, and stdout/no-stdout + command runners. #### Changed @@ -85,6 +90,11 @@ error system (ADR-T-006), MSRV raised to 1.88. `private_key_pem`, and `public_key_pem`; `torrust-index-config-probe` emits `schema`, `database`, and `auth`; `torrust-index-health-check` emits `schema`, `target`, `status`, and `elapsed_ms`. +- `torrust-index-auth-keypair`, `torrust-index-config-probe`, and + `torrust-index-health-check` now use the shared JSON `clap` parser and JSON + panic hook. Their `--help`, `--version`, argv errors, TTY refusal, and panic + diagnostics are JSON control-plane records on stderr; stdout remains reserved + for successful result JSON. - Operator documentation now describes the ADR-T-010 migration state: helper binaries have the JSON stdout contract, while root maintenance binaries and the container entry script remain legacy output gaps until their rollout diff --git a/README.md b/README.md index 2b6c936e..21c9b91d 100644 --- a/README.md +++ b/README.md @@ -170,8 +170,9 @@ reserved for machine-readable result data, stderr is reserved for machine-readable diagnostics/control records, and commands that emit stdout result data refuse to write it directly to a terminal. -The first implemented stage fixes the shared contract shape and the helper -result schemas used by the container runtime. These helpers emit exactly one +The shared helper infrastructure now wraps `clap` help, version, and usage +errors as JSON control-plane records on stderr, installs a JSON-only panic hook, +and uses JSON tracing on stderr. The container helper binaries emit exactly one JSON object on stdout when successful, include a top-level `schema` field, and should be inspected through a pipe or redirect: @@ -181,6 +182,10 @@ torrust-index-config-probe | jq . torrust-index-health-check http://127.0.0.1:3001/health_check | jq . ``` +For helper diagnostics, a non-empty `RUST_LOG` environment variable takes +precedence over `--debug`; otherwise `--debug` raises the default diagnostic +filter to debug. + The older root maintenance binaries (`parse_torrent`, `create_test_torrent`, `import_tracker_statistics`, `seeder`, and `upgrade`) are in ADR-T-010 scope but are still migration targets. Do not build new automation around their current diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index a4afa575..bdfca221 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -225,11 +225,13 @@ longer exists. Design: - Refuses to run if stdout is a terminal (exit code 2). - Emits a single JSON object - `{"private_key_pem": "...", "public_key_pem": "..."}` + `{"schema": 1, "private_key_pem": "...", "public_key_pem": "..."}` on stdout ([ADR-T-010](010-global-command-line-output-contract.md)). The original raw-PEM output was replaced in Phase 2. -- Diagnostics on stderr via `tracing` (NDJSON); `--debug` for verbose. -- Uses `clap` for CLI. +- Diagnostics on stderr via JSON `tracing` (NDJSON); `RUST_LOG` takes + precedence over `--debug` for filter selection. +- Uses the shared ADR-T-010 `clap` wrapper, so `--help`, `--version`, and argv + errors are JSON control-plane records on stderr. #### Container integration diff --git a/adr/009-container-infrastructure-refactor.md b/adr/009-container-infrastructure-refactor.md index 2393349c..3a3d0de0 100644 --- a/adr/009-container-infrastructure-refactor.md +++ b/adr/009-container-infrastructure-refactor.md @@ -50,7 +50,7 @@ The decisions below follow from a small set of invariants the container subsyste - **P6.** The compose baseline is production-shaped; dev affordances are an additive override layer, never a subtraction from the baseline. - **P7.** Vendored security-sensitive code is treated as code we own, with a current internal audit record. - **P8.** Helper binaries implement the TTY-refusal rule now defined globally by [ADR-T-010](010-global-command-line-output-contract.md): commands that emit stdout result data refuse to write it directly to a terminal. -- **P9.** Helper binaries implement the stdout/stderr contract now defined globally by [ADR-T-010](010-global-command-line-output-contract.md). This ADR keeps one helper-specific dependency consequence: every helper binary links the same baseline crates without exception or per-crate justification: `clap` (argv), `tracing` + `tracing-subscriber` with `json` feature (stderr diagnostics), `serde` + `serde_json` (stdout wire format). These are not enumerated in per-crate allowlists. A shared `torrust-index-cli-common` library crate provides the scaffolding (`refuse_if_stdout_is_tty`, `init_json_tracing`, `emit`, and a common `BaseArgs` with `--debug`). +- **P9.** Helper binaries implement the stdout/stderr contract now defined globally by [ADR-T-010](010-global-command-line-output-contract.md). This ADR keeps one helper-specific dependency consequence: every helper binary links the same baseline crates without exception or per-crate justification: `clap` (argv), `tracing` + `tracing-subscriber` with `env-filter` and `json` features (stderr diagnostics), `serde` + `serde_json` (stdout wire format). These are not enumerated in per-crate allowlists. A shared `torrust-index-cli-common` library crate provides the scaffolding: JSON `clap` wrapping, TTY refusal, JSON tracing, JSON panic diagnostics, stdout JSON emission, command runners, and a common `BaseArgs` with `--debug`. --- @@ -277,6 +277,9 @@ probe, the same mechanism the application uses. Refuses to run when stdout is a TTY (exit 2, per ADR-T-010). +`--help`, `--version`, argv errors, TTY refusal, and panic diagnostics are JSON +control-plane records on stderr. They do not emit stdout result data. + On success (exit 0), emits one JSON object + trailing newline on stdout: { @@ -736,7 +739,7 @@ The release-base symlink loop covers every applet the entry script invokes by ba **Follows from:** P2, P8, P9, and the global command-line output contract later extracted as ADR-T-010. **Addresses:** [R4](#r4--health_check-pulls-in-reqwest-for-a-localhost-get). -Every helper binary is extracted into its own workspace crate under `packages/index-*/` and follows the command-line output contract now defined globally by ADR-T-010. A shared `packages/index-cli-common/` library crate (`torrust-index-cli-common`) provides the scaffolding so each binary's `main` is only domain logic. +Every helper binary is extracted into its own workspace crate under `packages/index-*/` and follows the command-line output contract now defined globally by ADR-T-010. A shared `packages/index-cli-common/` library crate (`torrust-index-cli-common`) provides the scaffolding so each binary's `main` is only domain logic and command-boundary wiring. The crate boundary makes the "no HTTP/TLS deps" property a manifest-level invariant: a future contributor cannot accidentally re-introduce `reqwest` because the crate's `Cargo.toml` simply does not list it. `reqwest` remains in the workspace for the importer and tracker clients; the goal is to prune it from the *helper binaries'* dep closures, not from the workspace. @@ -757,16 +760,45 @@ The dep-closure exclusion check ([Acceptance Criterion #5](#5-helper-binary-dep- **Public API:** ```rust +/// Parse argv with clap, emitting JSON help/version/usage records on stderr. +pub fn parse_args_or_exit() -> T; + /// Refuse to run if stdout is a terminal (ADR-T-010). -/// Prints a diagnostic to stderr and exits with code 2. +/// Emits a JSON control-plane record to stderr and exits with code 2. pub fn refuse_if_stdout_is_tty(binary_name: &str); -/// Initialise `tracing-subscriber` with JSON output on stderr. -pub fn init_json_tracing(level: tracing::Level); +/// Initialise JSON stderr tracing with RUST_LOG / --debug precedence. +pub fn init_json_tracing_with_debug(debug: bool, default_level: tracing::Level); + +/// Install the JSON-only panic hook. +pub fn install_json_panic_hook(command_name: &str); /// Serialise `value` as one JSON object + trailing newline to stdout. pub fn emit(value: &T) -> std::io::Result<()>; +/// Run a stdout-producing single-JSON-object command. +pub fn run_stdout_json_command( + command_name: &str, + debug: bool, + default_level: tracing::Level, + run: Run, +) -> std::process::ExitCode +where + Output: serde::Serialize, + CommandError: std::fmt::Display, + Run: FnOnce() -> Result; + +/// Run a side-effect command that does not emit stdout result data. +pub fn run_no_stdout_command( + command_name: &str, + debug: bool, + default_level: tracing::Level, + run: Run, +) -> std::process::ExitCode +where + CommandError: std::fmt::Display, + Run: FnOnce() -> Result<(), CommandError>; + /// Common `--debug` flag. Flatten into each binary's `Args` via `#[command(flatten)]`. #[derive(clap::Args)] pub struct BaseArgs { @@ -775,19 +807,16 @@ pub struct BaseArgs { } ``` -**Dependencies.** The ADR-T-010 helper baseline and nothing else: `clap`, `tracing`, `tracing-subscriber` (with `json` feature), `serde`, `serde_json`. +**Dependencies.** The ADR-T-010 helper baseline and nothing else: `clap`, `tracing`, `tracing-subscriber` (with `env-filter` and `json` features), `serde`, `serde_json`. -Every binary's `main` reduces to: +For helpers whose errors map to ADR-T-010's baseline exit classes, `main` +reduces to: ```rust fn main() -> std::process::ExitCode { - let args = Args::parse(); - refuse_if_stdout_is_tty("torrust-index-"); - init_json_tracing(if args.base.debug { Level::DEBUG } else { Level::INFO }); - match run(&args) { - Ok(out) => { emit(&out).unwrap(); ExitCode::SUCCESS } - Err(e) => { error!(error = %e, "…"); ExitCode::from(e.exit_code()) } - } + install_json_panic_hook("torrust-index-"); + let args = parse_args_or_exit::(); + run_stdout_json_command("torrust-index-", args.base.debug, Level::INFO, || run(&args)) } ``` @@ -797,7 +826,7 @@ Moved from `src/bin/health_check.rs` to `packages/index-health-check/`. Rewritte Stdout result JSON on success: ```json -{"target": "http://localhost:3001/health_check", "status": 200, "elapsed_ms": 4} +{"schema": 1, "target": "http://localhost:3001/health_check", "status": 200, "elapsed_ms": 4} ``` On failure, stdout is empty; the exit code is the sole branch signal for callers (Docker, the entry script). Tests cover non-2xx response, connection refused, read timeout, and malformed status line using a `TcpListener` on an ephemeral port. @@ -808,10 +837,10 @@ Moved from `src/bin/generate_auth_keypair.rs` to `packages/index-auth-keypair/`. Stdout result JSON: ```json -{"private_key_pem": "-----BEGIN PRIVATE KEY-----\n...", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n..."} +{"schema": 1, "private_key_pem": "-----BEGIN PRIVATE KEY-----\n...", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n..."} ``` -This eliminated the `sed` post-processing in the previous documented usage. Consumers use `jq -r .private_key_pem` (shell) or `serde_json::from_reader::` (Rust). The existing TTY guard migrated to the shared `refuse_if_stdout_is_tty`, unifying on exit code 2 (was exit 1). +This eliminated the `sed` post-processing in the previous documented usage. Consumers use `jq -r .private_key_pem` (shell) or `serde_json::from_reader::` (Rust). The existing TTY guard migrated to the shared ADR-T-010 infrastructure, unifying on exit code 2 (was exit 1). The entry script's keygen invocation changed from `torrust-generate-auth-keypair` to `torrust-index-auth-keypair`, and the consumer migrated from `sed` PEM-block extraction to `jq` in the same change (`sed` cannot recover usable PEM from the new single-line JSON output). @@ -1121,7 +1150,7 @@ exit 0 ### 6. Helper JSON + TTY contract (ADR-T-010) -Every helper binary, when invoked with stdout attached to a TTY, exits with code 2 before producing any output. When invoked with stdout piped, every helper emits exactly one JSON object followed by one trailing newline on stdout, and `tracing` NDJSON events on stderr. This is the helper-binary acceptance slice of the global contract later extracted as ADR-T-010. +Every helper binary, when invoked with stdout attached to a TTY, exits with code 2 before producing any stdout. When invoked with stdout piped, every helper emits exactly one JSON object followed by one trailing newline on stdout, and JSON/NDJSON control-plane or tracing records on stderr. Help, version, argv errors, TTY refusal, and panic diagnostics are JSON control-plane records on stderr. This is the helper-binary acceptance slice of the global contract later extracted as ADR-T-010. ```sh set -eu diff --git a/adr/010-global-command-line-output-contract.md b/adr/010-global-command-line-output-contract.md index abaac69d..6c209cf9 100644 --- a/adr/010-global-command-line-output-contract.md +++ b/adr/010-global-command-line-output-contract.md @@ -75,7 +75,9 @@ Command-specific diagnostics should not use `println!` or `eprintln!` for progre ## Implementation Guidance -Use `torrust-index-cli-common` for command-line tools that emit a single JSON object on stdout. It provides the current shared scaffolding for the global contract: TTY refusal for commands with stdout result data, JSON tracing on stderr, JSON emission on stdout, and the common `--debug` flag. +Use `torrust-index-cli-common` for Rust command-line tools. It provides the shared scaffolding for the global contract: JSON wrapping for `clap` help, version, and argv errors; JSON control-plane records on stderr before tracing is installed; TTY refusal for commands with stdout result data; JSON-only panic diagnostics; JSON tracing on stderr; JSON emission on stdout; the common `--debug` flag; and runners for stdout-producing and no-stdout command classes. + +Tracing filter precedence is shared: a non-empty `RUST_LOG` environment variable wins, otherwise `--debug` selects debug-level diagnostics, otherwise the command's default level is used. Commands that do not emit stdout result data still follow the stream separation rule: diagnostics and logs go to stderr as JSON, preferably through `tracing`. diff --git a/docs/containers.md b/docs/containers.md index 8ab5a05a..bba454c6 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -409,8 +409,8 @@ failure rather than silent misbehaviour. Container helper binaries follow the ADR-T-010 stdout/stderr split. When they emit result data, stdout is exactly one JSON object with a trailing newline and -a top-level `schema` field. Diagnostics are emitted on stderr as JSON tracing -records where the helper has already been migrated. +a top-level `schema` field. Diagnostics, help, version output, argv errors, TTY +refusal, and panic reports are emitted on stderr as JSON records. The stdout-producing helpers are: @@ -427,6 +427,12 @@ torrust-index-config-probe | jq . torrust-index-health-check http://127.0.0.1:3001/health_check | jq . ``` +For helper diagnostics, a non-empty `RUST_LOG` environment variable takes +precedence over `--debug`; otherwise `--debug` raises the helper's default +diagnostic filter to debug. Help and version requests do not emit stdout result +data and therefore do not trigger TTY refusal; they write JSON control-plane +records to stderr and exit with code 0. + The container entry script captures helper stdout internally and does not forward it to the terminal. Its own diagnostics are still part of the ADR-T-010 migration backlog; until that rollout stage lands, treat any plain-text entry diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index e98e47d4..2fae2fc3 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -9,6 +9,24 @@ This is an implementation plan for ADR-T-010, not a separate ADR. Its job is to turn the decided repository-wide command-line output contract into concrete code, documentation, and regression tests. +## Current Implementation Status + +Stages 1 through 3 have landed for the shared Rust helper path: + +- Stage 1 fixed the shared control-plane record shape, baseline exit classes, + helper stdout schemas, and redaction helpers. +- Stage 2 expanded `torrust-index-cli-common` with JSON `clap` handling, direct + JSON stderr control-plane emission, the JSON panic hook, idempotent JSON + stderr tracing with `RUST_LOG` / `--debug` precedence, a non-interleaving + stderr writer, and command runners. +- Stage 3 wired `torrust-index-auth-keypair`, `torrust-index-config-probe`, and + `torrust-index-health-check` to the expanded shared infrastructure. Their + help, version, argv errors, TTY refusal, and panic diagnostics are now JSON + control-plane records on stderr. + +The root server, root maintenance binaries, and container entry script remain +future rollout stages unless their sections below say otherwise. + ## Goal Bring every shipped, documented, or operator-facing first-party command-line @@ -186,6 +204,12 @@ Commands with no stdout result data: Update `packages/index-cli-common` so every Rust binary can share the same contract implementation instead of open-coding it. +Stage 2 status: implemented. The shared crate now owns the control-plane record +writer, `parse_args_or_exit::()`, the JSON panic hook, `RUST_LOG` / `--debug` +tracing precedence, locked stderr JSON tracing, and stdout/no-stdout command +runners. The helper binaries use these entrypoints; root binaries will migrate +in later stages. + Required changes: - Define the shared JSON control-plane record shape, including a schema/version @@ -297,6 +321,12 @@ Required changes: Update the helper binaries under `packages/index-*`. +Stage 3 status: implemented for the three container helpers. They use the shared +JSON clap parser, install the shared JSON panic hook, expose `--version` through +clap metadata, keep their stdout result schemas unchanged, and preserve TTY +refusal for stdout result data. `torrust-index-config-probe` no longer preserves +Rust's default plain-text panic output. + Required changes: - Use the shared JSON clap parser in: @@ -417,11 +447,12 @@ Required changes: Update operator documentation after the behavior changes. -Stage 1 documentation status: the shared contract shape, helper stdout result -schemas, and migration-status notes have been documented. Root maintenance -commands and the container entry script are still legacy output gaps until their -rollout stages land; their documentation should describe the ADR-T-010 target -contract without promising behaviour the binaries do not yet implement. +Current documentation status: the shared contract shape, helper stdout result +schemas, expanded Rust CLI infrastructure, and helper-binary wiring state have +been documented. Root maintenance commands and the container entry script are +still legacy output gaps until their rollout stages land; their documentation +should describe the ADR-T-010 target contract without promising behaviour the +binaries do not yet implement. Required changes: @@ -493,6 +524,11 @@ summarizing results, following the repository test-running convention. ## Rollout Order +Current status: steps 1 through 3 have landed. The documentation for those +stages has been updated as part of the stage-two/three rollout; the later +operator-visible migrations still need their own documentation and changelog +updates when they land. + 1. Finalize the shared control-plane record shape, command-specific result schema details, exit-code mapping, and redaction rules. 2. Extend `torrust-index-cli-common` with JSON clap handling, JSON panic hooks, diff --git a/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs b/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs index 45647979..08d93746 100644 --- a/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs +++ b/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs @@ -13,13 +13,16 @@ use std::process::ExitCode; use clap::Parser; -use torrust_index_auth_keypair::generate_keypair; -use torrust_index_cli_common::{BaseArgs, emit, init_json_tracing, refuse_if_stdout_is_tty}; -use tracing::{error, info}; +use torrust_index_auth_keypair::{KeypairOutput, generate_keypair}; +use torrust_index_cli_common::{BaseArgs, install_json_panic_hook, parse_args_or_exit, run_stdout_json_command}; +use tracing::info; + +const COMMAND_NAME: &str = "torrust-index-auth-keypair"; #[derive(Parser)] #[command( name = "torrust-index-auth-keypair", + version, about = "Generate an RSA-2048 key pair for Torrust Index JWT authentication" )] struct Args { @@ -28,32 +31,82 @@ struct Args { } fn main() -> ExitCode { - let args = Args::parse(); - init_json_tracing(if args.base.debug { - tracing::Level::DEBUG - } else { - tracing::Level::INFO - }); - refuse_if_stdout_is_tty("torrust-index-auth-keypair"); - - info!("Generating RSA-2048 key pair..."); - - match generate_keypair() { - Ok(out) => { - info!("Key pair generated successfully."); - match emit(&out) { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - // Writing to stdout failed (e.g. broken pipe). Honour - // the helper's exit-code contract instead of panicking. - error!(error = %e, "failed to write key pair to stdout"); - ExitCode::FAILURE - } - } - } - Err(e) => { - error!(error = %e, "keypair generation failed"); - ExitCode::FAILURE - } + install_json_panic_hook(COMMAND_NAME); + + let args = parse_args_or_exit::(); + + run_stdout_json_command::(COMMAND_NAME, args.base.debug, tracing::Level::INFO, || { + info!("generating RSA-2048 key pair"); + generate_keypair() + }) +} + +#[cfg(test)] +mod tests { + //! # Auth-keypair binary CLI contract tests + //! + //! | Test | What it covers | + //! |---------------------------------------|----------------------------------------| + //! | `help_is_json_control_record` | `--help` is wrapped as JSON metadata | + //! | `version_is_json_control_record` | `--version` is wrapped as JSON metadata| + //! | `usage_error_is_json_control_record` | argv errors become JSON usage records | + + use torrust_index_cli_common::{CommandExit, ControlPlaneFields, ControlPlaneRecordKind, parse_args_from}; + + use super::{Args, COMMAND_NAME}; + + #[test] + fn help_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--help"]) else { + panic!("help should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Help); + + let Some(ControlPlaneFields::Help { text }) = exit.record.fields else { + panic!("help record should carry help text"); + }; + assert!(text.contains("Generate an RSA-2048 key pair")); + assert!(text.contains("--debug")); + } + + #[test] + fn version_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--version"]) else { + panic!("version should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Version); + + let Some(ControlPlaneFields::Version { version }) = exit.record.fields else { + panic!("version record should carry version text"); + }; + assert_eq!(version, format!("{COMMAND_NAME} {}", env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn usage_error_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--no-such-flag"]) else { + panic!("unknown flags should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Usage); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::UsageError); + assert!(exit.record.message.contains("--no-such-flag")); + + let Some(ControlPlaneFields::UsageError { + exit_code, + clap_error_kind, + }) = exit.record.fields + else { + panic!("usage record should carry usage fields"); + }; + assert_eq!(exit_code, CommandExit::Usage.code()); + assert_eq!(clap_error_kind, "unknown_argument"); } } diff --git a/packages/index-cli-common/Cargo.toml b/packages/index-cli-common/Cargo.toml index 4c8e516b..56d7b412 100644 --- a/packages/index-cli-common/Cargo.toml +++ b/packages/index-cli-common/Cargo.toml @@ -15,7 +15,7 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0" -tracing-subscriber = { version = "0", features = ["json"] } +tracing-subscriber = { version = "0", features = ["env-filter", "json"] } url = "2" [lints] diff --git a/packages/index-cli-common/src/lib.rs b/packages/index-cli-common/src/lib.rs index 501c9911..844714e0 100644 --- a/packages/index-cli-common/src/lib.rs +++ b/packages/index-cli-common/src/lib.rs @@ -1,15 +1,24 @@ //! Shared CLI scaffolding for Torrust Index command-line tools (ADR-T-010). //! //! Every helper binary uses this crate for: +//! - JSON wrapping for `clap` help, version, and argv errors +//! - JSON control-plane records on stderr before tracing is installed //! - TTY refusal for stdout result data -//! - JSON tracing initialisation on stderr +//! - JSON panic diagnostics without Rust's default text panic hook +//! - JSON tracing initialisation on stderr with `RUST_LOG` / `--debug` precedence //! - JSON output on stdout +//! - small runners for stdout-producing and no-stdout commands use std::borrow::Cow; +use std::ffi::{OsStr, OsString}; use std::io::{self, IsTerminal, Write}; use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard, TryLockError}; +use clap::{CommandFactory, Parser}; use serde::{Deserialize, Serialize}; +use tracing_subscriber::fmt::MakeWriter; #[cfg(test)] mod tests; @@ -20,6 +29,9 @@ pub const CONTROL_PLANE_SCHEMA: u32 = 1; /// Placeholder used when a diagnostic value is intentionally hidden. pub const REDACTED: &str = "[redacted]"; +static PANIC_REPORTED: AtomicBool = AtomicBool::new(false); +static STDERR_WRITE_LOCK: Mutex<()> = Mutex::new(()); + /// Baseline exit-code classes shared by ADR-T-010 command-line tools. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -206,6 +218,73 @@ impl ControlPlaneRecord { }), ) } + + /// Build a non-error status record. + #[must_use] + pub fn status(command: &str, message: &str) -> Self { + Self::new(command, ControlPlaneRecordKind::Status, message, None) + } + + /// Build a runtime diagnostic record. + #[must_use] + pub fn diagnostic(command: &str, message: &str) -> Self { + Self::new(command, ControlPlaneRecordKind::Diagnostic, message, None) + } +} + +/// Control-plane record and exit class produced while parsing argv. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliExit { + /// Record to write to stderr. + pub record: ControlPlaneRecord, + /// Process exit class to use after writing the record. + pub exit: CommandExit, +} + +impl CliExit { + /// Build a new parse-control exit value. + #[must_use] + pub const fn new(record: ControlPlaneRecord, exit: CommandExit) -> Self { + Self { record, exit } + } + + /// Return this exit class as a standard library [`ExitCode`]. + #[must_use] + pub fn exit_code(&self) -> ExitCode { + self.exit.exit_code() + } +} + +/// Source used to choose the JSON tracing filter directive. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TracingFilterSource { + /// The `RUST_LOG` environment variable supplied the directive. + RustLog, + /// The shared `--debug` flag selected debug logging. + DebugFlag, + /// The caller-supplied default level was used. + DefaultLevel, +} + +/// Resolved JSON tracing filter directive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TracingFilter { + /// Directive passed to `tracing-subscriber`'s environment filter. + pub directive: String, + /// Input source that selected the directive. + pub source: TracingFilterSource, +} + +impl TracingFilter { + /// Build a resolved tracing filter directive. + #[must_use] + pub fn new(directive: impl Into, source: TracingFilterSource) -> Self { + Self { + directive: directive.into(), + source, + } + } } /// Return true when a diagnostic field name is likely to contain a secret. @@ -305,28 +384,224 @@ fn is_sensitive_query_key(key: &str) -> bool { normalized == "key" || normalized.ends_with("_key") || is_sensitive_field_name(key) } +/// Resolve the JSON tracing filter with ADR-T-010 precedence. +/// +/// `RUST_LOG` wins when set and non-empty. Otherwise `--debug` selects `debug`, +/// and the caller-supplied default level is used last. +#[must_use] +pub fn tracing_filter(debug: bool, default_level: tracing::Level) -> TracingFilter { + tracing_filter_from_rust_log(std::env::var_os("RUST_LOG").as_deref(), debug, default_level) +} + +fn tracing_filter_from_rust_log(rust_log: Option<&OsStr>, debug: bool, default_level: tracing::Level) -> TracingFilter { + if let Some(value) = rust_log { + let directive = value.to_string_lossy(); + let trimmed = directive.trim(); + if !trimmed.is_empty() { + return TracingFilter::new(trimmed, TracingFilterSource::RustLog); + } + } + + if debug { + TracingFilter::new("debug", TracingFilterSource::DebugFlag) + } else { + TracingFilter::new(level_directive(default_level), TracingFilterSource::DefaultLevel) + } +} + +fn level_directive(level: tracing::Level) -> String { + level.as_str().to_ascii_lowercase() +} + +/// Parse argv with `clap`, converting help, version, and usage errors into +/// ADR-T-010 JSON control-plane records. +/// +/// # Errors +/// +/// Returns [`CliExit`] when parsing should stop and the caller should write the +/// enclosed record to stderr before exiting with the enclosed exit class. +pub fn parse_args_from(args: I) -> Result +where + T: Parser, + I: IntoIterator, + A: Into + Clone, +{ + T::try_parse_from(args).map_err(|clap_error| cli_exit_from_clap_error::(&clap_error)) +} + +/// Parse process argv with `clap`, writing ADR-T-010 control-plane records and +/// exiting when parsing is a help, version, or usage-control path. +#[must_use] +pub fn parse_args_or_exit() -> T +where + T: Parser, +{ + match parse_args_from::(std::env::args_os()) { + Ok(args) => args, + Err(exit) => { + let _ignored = emit_control_plane_record(&exit.record); + exit_with(exit.exit); + } + } +} + +fn cli_exit_from_clap_error(clap_error: &clap::Error) -> CliExit +where + T: CommandFactory, +{ + let command_name = T::command().get_name().to_string(); + let text = clap_error.to_string().trim_end().to_string(); + + match clap_error.kind() { + clap::error::ErrorKind::DisplayHelp => CliExit::new(ControlPlaneRecord::help(&command_name, &text), CommandExit::Success), + clap::error::ErrorKind::DisplayVersion => { + CliExit::new(ControlPlaneRecord::version(&command_name, &text), CommandExit::Success) + } + _ => CliExit::new( + ControlPlaneRecord::usage_error(&command_name, &text, &clap_error_kind_name(clap_error.kind())), + CommandExit::Usage, + ), + } +} + +fn clap_error_kind_name(kind: clap::error::ErrorKind) -> String { + debug_name_to_snake_case(&format!("{kind:?}")) +} + +fn debug_name_to_snake_case(name: &str) -> String { + let mut normalized = String::with_capacity(name.len()); + let mut previous_was_lower_or_digit = false; + + for character in name.chars() { + if character.is_ascii_uppercase() { + if !normalized.is_empty() && previous_was_lower_or_digit { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + previous_was_lower_or_digit = false; + } else { + normalized.push(character); + previous_was_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit(); + } + } + + normalized +} + +/// Write one ADR-T-010 control-plane JSON record to stderr. +/// +/// This helper does not depend on `tracing`, so it is safe for clap help, +/// clap parse errors, early startup failures, TTY refusal, and panic hooks. +/// +/// # Errors +/// +/// Returns an error if serialisation or writing to stderr fails. +pub fn emit_control_plane_record(record: &ControlPlaneRecord) -> io::Result<()> { + let mut writer = LockedStderrWriter::new(); + write_json_line(&mut writer, record) +} + +fn try_emit_control_plane_record(record: &ControlPlaneRecord) -> io::Result { + let Some(mut writer) = LockedStderrWriter::try_new() else { + return Ok(false); + }; + + write_json_line(&mut writer, record)?; + Ok(true) +} + +fn write_json_line(writer: &mut W, value: &T) -> io::Result<()> { + let json = serde_json::to_string(value)?; + writer.write_all(json.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush() +} + +/// Install a JSON-only panic hook for ADR-T-010 command-line entrypoints. +/// +/// The hook emits one best-effort control-plane record on stderr and terminates +/// the process with exit code 1 without invoking Rust's default text panic hook. +pub fn install_json_panic_hook(command_name: &str) { + let command_name = command_name.to_string(); + std::panic::set_hook(Box::new(move |panic_info| { + if !PANIC_REPORTED.swap(true, Ordering::SeqCst) { + let current_thread = std::thread::current(); + let thread_name = current_thread.name(); + let location = panic_info + .location() + .map(|location| format!("{}:{}:{}", location.file(), location.line(), location.column())); + let record = ControlPlaneRecord::panic(&command_name, thread_name, location.as_deref()); + let _ignored = try_emit_control_plane_record(&record); + } + + exit_with(CommandExit::Failure); + })); +} + +/// Exit the current process with an ADR-T-010 exit class. +pub fn exit_with(exit: CommandExit) -> ! { + std::process::exit(i32::from(exit.code())); +} + +/// Return a TTY-refusal record when stdout is attached to a terminal. +#[must_use] +pub fn stdout_tty_refusal_record(command_name: &str) -> Option { + if io::stdout().is_terminal() { + Some(ControlPlaneRecord::tty_refusal(command_name)) + } else { + None + } +} + /// Refuse to run if stdout is a terminal (ADR-T-010). /// -/// Emits a `tracing::error!` event (NDJSON on stderr, per ADR-T-010) -/// and exits with code 2. Call this **after** [`init_json_tracing`] -/// so the diagnostic is structured rather than a bare `eprintln!`. +/// Emits a JSON control-plane record on stderr and exits with code 2. pub fn refuse_if_stdout_is_tty(binary_name: &str) { - if io::stdout().is_terminal() { - tracing::error!( - binary = binary_name, - "stdout is a terminal \u{2014} pipe to a file or another process" - ); - std::process::exit(2); + if let Some(record) = stdout_tty_refusal_record(binary_name) { + let _ignored = emit_control_plane_record(&record); + exit_with(CommandExit::Usage); } } /// Initialise `tracing-subscriber` with JSON output on stderr. pub fn init_json_tracing(level: tracing::Level) { + let _installed = try_init_json_tracing(level); +} + +/// Try to initialise `tracing-subscriber` with JSON output on stderr. +/// +/// Returns `false` if another subscriber is already installed. +#[must_use] +pub fn try_init_json_tracing(level: tracing::Level) -> bool { + let filter = tracing_filter(false, level); + try_init_json_tracing_with_directive(&filter.directive) +} + +/// Initialise JSON stderr tracing with `RUST_LOG` / `--debug` precedence. +pub fn init_json_tracing_with_debug(debug: bool, default_level: tracing::Level) { + let _installed = try_init_json_tracing_with_debug(debug, default_level); +} + +/// Try to initialise JSON stderr tracing with `RUST_LOG` / `--debug` precedence. +/// +/// Returns `false` if another subscriber is already installed. +#[must_use] +pub fn try_init_json_tracing_with_debug(debug: bool, default_level: tracing::Level) -> bool { + let filter = tracing_filter(debug, default_level); + try_init_json_tracing_with_directive(&filter.directive) +} + +fn try_init_json_tracing_with_directive(filter_directive: &str) -> bool { + let fallback_directive = level_directive(tracing::Level::INFO); + let filter = tracing_subscriber::EnvFilter::try_new(filter_directive) + .unwrap_or_else(|_error| tracing_subscriber::EnvFilter::new(fallback_directive)); + tracing_subscriber::fmt() .json() - .with_max_level(level) - .with_writer(io::stderr) - .init(); + .with_env_filter(filter) + .with_writer(LockedStderr) + .try_init() + .is_ok() } /// Serialise `value` as one JSON object + trailing newline to stdout. @@ -335,12 +610,123 @@ pub fn init_json_tracing(level: tracing::Level) { /// /// Returns an error if serialisation or writing to stdout fails. pub fn emit(value: &T) -> io::Result<()> { - let json = serde_json::to_string(value)?; let stdout = io::stdout(); let mut out = stdout.lock(); - out.write_all(json.as_bytes())?; - out.write_all(b"\n")?; - out.flush() + write_json_line(&mut out, value) +} + +/// Run a stdout-producing single-JSON-object command. +/// +/// The runner installs the JSON panic hook, initialises JSON stderr tracing, +/// refuses terminal stdout, writes successful result data to stdout, and maps +/// failures to ADR-T-010 baseline exit classes. +pub fn run_stdout_json_command( + command_name: &str, + debug: bool, + default_level: tracing::Level, + run: Run, +) -> ExitCode +where + Output: serde::Serialize, + CommandError: std::fmt::Display, + Run: FnOnce() -> Result, +{ + install_json_panic_hook(command_name); + init_json_tracing_with_debug(debug, default_level); + + if let Some(record) = stdout_tty_refusal_record(command_name) { + let _ignored = emit_control_plane_record(&record); + return CommandExit::Usage.exit_code(); + } + + match run() { + Ok(output) => match emit(&output) { + Ok(()) => CommandExit::Success.exit_code(), + Err(error) => { + tracing::error!(error = %error, "failed to write JSON to stdout"); + CommandExit::Failure.exit_code() + } + }, + Err(error) => { + tracing::error!(error = %error, "command failed"); + CommandExit::Failure.exit_code() + } + } +} + +/// Run a side-effect command that does not emit stdout result data. +/// +/// The runner installs the JSON panic hook, initialises JSON stderr tracing, and +/// maps failures to ADR-T-010 baseline exit classes. It deliberately does not +/// perform stdout TTY refusal because the command has no stdout result data. +pub fn run_no_stdout_command( + command_name: &str, + debug: bool, + default_level: tracing::Level, + run: Run, +) -> ExitCode +where + CommandError: std::fmt::Display, + Run: FnOnce() -> Result<(), CommandError>, +{ + install_json_panic_hook(command_name); + init_json_tracing_with_debug(debug, default_level); + + match run() { + Ok(()) => CommandExit::Success.exit_code(), + Err(error) => { + tracing::error!(error = %error, "command failed"); + CommandExit::Failure.exit_code() + } + } +} + +struct LockedStderr; + +impl<'writer> MakeWriter<'writer> for LockedStderr { + type Writer = LockedStderrWriter; + + fn make_writer(&'writer self) -> Self::Writer { + LockedStderrWriter::new() + } +} + +struct LockedStderrWriter { + _guard: MutexGuard<'static, ()>, + stderr: io::Stderr, +} + +impl LockedStderrWriter { + fn new() -> Self { + Self { + _guard: STDERR_WRITE_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner), + stderr: io::stderr(), + } + } + + fn try_new() -> Option { + match STDERR_WRITE_LOCK.try_lock() { + Ok(guard) => Some(Self { + _guard: guard, + stderr: io::stderr(), + }), + Err(TryLockError::Poisoned(poisoned)) => Some(Self { + _guard: poisoned.into_inner(), + stderr: io::stderr(), + }), + Err(TryLockError::WouldBlock) => None, + } + } +} + +impl Write for LockedStderrWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.stderr.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.stderr.flush() + } } /// Common `--debug` flag for all helpers. Flatten into each diff --git a/packages/index-cli-common/src/tests/mod.rs b/packages/index-cli-common/src/tests/mod.rs index 18f25a92..9df850e6 100644 --- a/packages/index-cli-common/src/tests/mod.rs +++ b/packages/index-cli-common/src/tests/mod.rs @@ -10,9 +10,16 @@ //! | `base_args_parses_long_flag` | `--debug` flips `BaseArgs::debug` to `true`. | //! | `command_exit_codes_match_contract` | Baseline process statuses are fixed. | //! | `control_record_serialises_shape` | Shared stderr record shape is stable. | +//! | `json_line_writer_appends_newline` | JSON record helper writes one complete line. | //! | `usage_error_record_carries_fields` | Usage records include exit code and clap kind. | //! | `tty_refusal_record_carries_fields` | TTY refusal records identify stdout and code 2. | //! | `panic_record_omits_payload` | Panic records avoid serialising panic payloads. | +//! | `parse_args_from_returns_help_record` | Clap help becomes JSON stderr control data. | +//! | `parse_args_from_returns_version_record` | Clap version becomes JSON stderr control data. | +//! | `parse_args_from_returns_usage_record` | Clap argv errors become JSON usage records. | +//! | `tracing_filter_prefers_rust_log` | `RUST_LOG` wins over `--debug`. | +//! | `tracing_filter_uses_debug_flag` | `--debug` selects debug without `RUST_LOG`. | +//! | `tracing_filter_uses_default_level` | Default level is used last. | //! | `redacts_sensitive_field_values` | Secret-like field names are hidden. | //! | `redacts_database_url_secrets` | DB credentials and query secrets are removed. | //! | `keeps_public_key_fields_visible` | Public key metadata is not treated as secret. | @@ -26,6 +33,7 @@ //! interferes with the rest of the test binary's output. use std::collections::BTreeMap; +use std::ffi::OsStr; use std::io::{self, Write}; use clap::Parser; @@ -34,7 +42,8 @@ use serde_json::json; use crate::{ BaseArgs, CONTROL_PLANE_SCHEMA, CommandExit, ControlPlaneFields, ControlPlaneRecord, ControlPlaneRecordKind, REDACTED, - StandardStream, redact_database_url, redact_field_value, + StandardStream, TracingFilterSource, parse_args_from, redact_database_url, redact_field_value, tracing_filter_from_rust_log, + write_json_line, }; /// A `Write` that fails every call with `BrokenPipe`. @@ -53,6 +62,17 @@ impl Write for FailingWriter { } } +#[derive(Parser)] +#[command(name = "fixture-helper", version = "1.2.3", about = "Fixture helper")] +#[allow(dead_code)] +struct FixtureCli { + #[arg(long)] + name: Option, + + #[command(flatten)] + base: BaseArgs, +} + /// Pure helper that mirrors `emit` but writes to an arbitrary /// `Write`. Lets us assert the on-the-wire bytes without /// touching the real `stdout()` lock (which would interleave @@ -168,6 +188,20 @@ fn control_record_serialises_shape() { assert!(value.get("fields").is_none()); } +#[test] +fn json_line_writer_appends_newline() { + let record = ControlPlaneRecord::status("fixture", "ready"); + let mut buf = Vec::new(); + + write_json_line(&mut buf, &record).expect("record should serialize"); + + assert!(buf.ends_with(b"\n")); + let line = std::str::from_utf8(&buf).expect("JSON must be UTF-8"); + let value: serde_json::Value = serde_json::from_str(line).expect("record must be a JSON object"); + assert_eq!(value["schema"], json!(CONTROL_PLANE_SCHEMA)); + assert_eq!(value["kind"], json!("status")); +} + #[test] fn usage_error_record_carries_fields() { let record = ControlPlaneRecord::usage_error("fixture", "unknown argument", "unknown_argument"); @@ -212,6 +246,85 @@ fn panic_record_omits_payload() { assert!(value["fields"].get("payload").is_none()); } +#[test] +fn parse_args_from_returns_help_record() { + let Err(exit) = parse_args_from::(["fixture-helper", "--help"]) else { + panic!("help should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, "fixture-helper"); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Help); + + let Some(ControlPlaneFields::Help { text }) = exit.record.fields else { + panic!("help record should carry help text"); + }; + assert!(text.contains("Fixture helper")); + assert!(text.contains("--debug")); +} + +#[test] +fn parse_args_from_returns_version_record() { + let Err(exit) = parse_args_from::(["fixture-helper", "--version"]) else { + panic!("version should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, "fixture-helper"); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Version); + + let Some(ControlPlaneFields::Version { version }) = exit.record.fields else { + panic!("version record should carry version text"); + }; + assert_eq!(version, "fixture-helper 1.2.3"); +} + +#[test] +fn parse_args_from_returns_usage_record() { + let Err(exit) = parse_args_from::(["fixture-helper", "--no-such-flag"]) else { + panic!("unknown flags should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Usage); + assert_eq!(exit.record.command, "fixture-helper"); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::UsageError); + assert!(exit.record.message.contains("--no-such-flag")); + + let Some(ControlPlaneFields::UsageError { + exit_code, + clap_error_kind, + }) = exit.record.fields + else { + panic!("usage record should carry usage fields"); + }; + assert_eq!(exit_code, CommandExit::Usage.code()); + assert_eq!(clap_error_kind, "unknown_argument"); +} + +#[test] +fn tracing_filter_prefers_rust_log() { + let filter = tracing_filter_from_rust_log(Some(OsStr::new("warn,tower_http=debug")), true, tracing::Level::INFO); + + assert_eq!(filter.directive, "warn,tower_http=debug"); + assert_eq!(filter.source, TracingFilterSource::RustLog); +} + +#[test] +fn tracing_filter_uses_debug_flag() { + let filter = tracing_filter_from_rust_log(Some(OsStr::new(" ")), true, tracing::Level::INFO); + + assert_eq!(filter.directive, "debug"); + assert_eq!(filter.source, TracingFilterSource::DebugFlag); +} + +#[test] +fn tracing_filter_uses_default_level() { + let filter = tracing_filter_from_rust_log(None, false, tracing::Level::WARN); + + assert_eq!(filter.directive, "warn"); + assert_eq!(filter.source, TracingFilterSource::DefaultLevel); +} + #[test] fn redacts_sensitive_field_values() { assert_eq!(redact_field_value("tracker.token", "MyAccessToken"), REDACTED); diff --git a/packages/index-cli-common/tests/public_api.rs b/packages/index-cli-common/tests/public_api.rs index e21a86de..c308d5ff 100644 --- a/packages/index-cli-common/tests/public_api.rs +++ b/packages/index-cli-common/tests/public_api.rs @@ -14,10 +14,11 @@ //! | `base_args_flattens_into_clap_parser` | `BaseArgs` composes via `#[command(flatten)]` | //! | `base_args_long_flag_toggles_debug` | The `--debug` long flag flips the field | //! | `base_args_rejects_unknown_short_flag` | clap rejects an unrelated flag at parse time | +//! | `parse_args_from_wraps_help_as_json_control_record` | shared parser returns JSON help metadata | use clap::Parser; use serde::Serialize; -use torrust_index_cli_common::{BaseArgs, emit}; +use torrust_index_cli_common::{BaseArgs, CommandExit, ControlPlaneFields, ControlPlaneRecordKind, emit, parse_args_from}; /// Minimal helper-binary-shaped CLI: every helper composes /// `BaseArgs` via `#[command(flatten)]`, so this fixture @@ -31,6 +32,14 @@ struct FixtureCli { base: BaseArgs, } +#[derive(Parser)] +#[command(name = "fixture-helper", version = "1.2.3", about = "Fixture helper")] +#[allow(dead_code)] +struct FixtureHelpCli { + #[command(flatten)] + base: BaseArgs, +} + #[test] fn emit_real_stdout_through_public_api() { // Under `cargo test`, stdout is captured by the harness, @@ -68,3 +77,18 @@ fn base_args_rejects_unknown_short_flag() { }; assert_eq!(err.exit_code(), 2, "clap argv-parse failure exit code is 2"); } + +#[test] +fn parse_args_from_wraps_help_as_json_control_record() { + let Err(exit) = parse_args_from::(["fixture-helper", "--help"]) else { + panic!("help should return a control-plane exit record"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Help); + + let Some(ControlPlaneFields::Help { text }) = exit.record.fields else { + panic!("help record should expose clap help text"); + }; + assert!(text.contains("Fixture helper")); +} diff --git a/packages/index-config-probe/src/bin/torrust-index-config-probe.rs b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs index 5e4e9e6c..c145555b 100644 --- a/packages/index-config-probe/src/bin/torrust-index-config-probe.rs +++ b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs @@ -8,14 +8,19 @@ use std::process::ExitCode; use clap::Parser; -use torrust_index_cli_common::{BaseArgs, emit, init_json_tracing, refuse_if_stdout_is_tty}; +use torrust_index_cli_common::{ + BaseArgs, emit, init_json_tracing_with_debug, install_json_panic_hook, parse_args_or_exit, refuse_if_stdout_is_tty, +}; use torrust_index_config::{DEFAULT_CONFIG_TOML_PATH, Info, load_settings}; use torrust_index_config_probe::{ProbeError, probe}; use tracing::error; +const COMMAND_NAME: &str = "torrust-index-config-probe"; + #[derive(Parser)] #[command( name = "torrust-index-config-probe", + version, about = "Emit the container-relevant subset of the resolved Torrust Index configuration as JSON" )] struct Args { @@ -24,15 +29,11 @@ struct Args { } fn main() -> ExitCode { - install_panic_hook(); + install_json_panic_hook(COMMAND_NAME); - let args = Args::parse(); - init_json_tracing(if args.base.debug { - tracing::Level::DEBUG - } else { - tracing::Level::INFO - }); - refuse_if_stdout_is_tty("torrust-index-config-probe"); + let args = parse_args_or_exit::(); + init_json_tracing_with_debug(args.base.debug, tracing::Level::INFO); + refuse_if_stdout_is_tty(COMMAND_NAME); // `Info::from_env` is the JSON-safe sibling of `Info::new`: // it reads the same env vars but skips the diagnostic @@ -66,16 +67,72 @@ fn main() -> ExitCode { } } -/// Map any unhandled panic to exit code 1 so the contract in -/// ADR-T-009 §D3 ("1 — Internal error (unhandled -/// panic, unexpected I/O)") is honoured. Without this hook a -/// panic would exit with Rust's default 101. -fn install_panic_hook() { - let default = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - // Preserve the default formatted backtrace on stderr, - // then exit with the documented code. - default(info); - std::process::exit(1); - })); +#[cfg(test)] +mod tests { + //! # Config-probe binary CLI contract tests + //! + //! | Test | What it covers | + //! |---------------------------------------|----------------------------------------| + //! | `help_is_json_control_record` | `--help` is wrapped as JSON metadata | + //! | `version_is_json_control_record` | `--version` is wrapped as JSON metadata| + //! | `usage_error_is_json_control_record` | argv errors become JSON usage records | + + use torrust_index_cli_common::{CommandExit, ControlPlaneFields, ControlPlaneRecordKind, parse_args_from}; + + use super::{Args, COMMAND_NAME}; + + #[test] + fn help_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--help"]) else { + panic!("help should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Help); + + let Some(ControlPlaneFields::Help { text }) = exit.record.fields else { + panic!("help record should carry help text"); + }; + assert!(text.contains("Emit the container-relevant subset")); + assert!(text.contains("--debug")); + } + + #[test] + fn version_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--version"]) else { + panic!("version should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Version); + + let Some(ControlPlaneFields::Version { version }) = exit.record.fields else { + panic!("version record should carry version text"); + }; + assert_eq!(version, format!("{COMMAND_NAME} {}", env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn usage_error_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--no-such-flag"]) else { + panic!("unknown flags should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Usage); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::UsageError); + assert!(exit.record.message.contains("--no-such-flag")); + + let Some(ControlPlaneFields::UsageError { + exit_code, + clap_error_kind, + }) = exit.record.fields + else { + panic!("usage record should carry usage fields"); + }; + assert_eq!(exit_code, CommandExit::Usage.code()); + assert_eq!(clap_error_kind, "unknown_argument"); + } } diff --git a/packages/index-health-check/src/bin/torrust-index-health-check.rs b/packages/index-health-check/src/bin/torrust-index-health-check.rs index 5873648d..1d9b5325 100644 --- a/packages/index-health-check/src/bin/torrust-index-health-check.rs +++ b/packages/index-health-check/src/bin/torrust-index-health-check.rs @@ -8,13 +8,15 @@ use std::process::ExitCode; use clap::Parser; -use torrust_index_cli_common::{BaseArgs, emit, init_json_tracing, refuse_if_stdout_is_tty}; -use torrust_index_health_check::do_health_check; -use tracing::error; +use torrust_index_cli_common::{BaseArgs, install_json_panic_hook, parse_args_or_exit, run_stdout_json_command}; +use torrust_index_health_check::{HealthCheckError, HealthCheckOutput, do_health_check}; + +const COMMAND_NAME: &str = "torrust-index-health-check"; #[derive(Parser)] #[command( name = "torrust-index-health-check", + version, about = "Minimal health-check for Torrust Index containers" )] struct Args { @@ -26,29 +28,81 @@ struct Args { } fn main() -> ExitCode { - let args = Args::parse(); - init_json_tracing(if args.base.debug { - tracing::Level::DEBUG - } else { - tracing::Level::INFO - }); - refuse_if_stdout_is_tty("torrust-index-health-check"); - - match do_health_check(&args.url) { - Ok(out) => match emit(&out) { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - // Writing the JSON object to stdout failed (e.g. broken - // pipe when the consumer has already exited). Surface - // the failure through the documented exit-code contract - // rather than letting Rust's panic handler set its own. - error!(error = %e, "failed to write health-check output to stdout"); - ExitCode::FAILURE - } - }, - Err(e) => { - error!(error = %e, "health check failed"); - ExitCode::FAILURE - } + install_json_panic_hook(COMMAND_NAME); + + let args = parse_args_or_exit::(); + + run_stdout_json_command::(COMMAND_NAME, args.base.debug, tracing::Level::INFO, || { + do_health_check(&args.url) + }) +} + +#[cfg(test)] +mod tests { + //! # Health-check binary CLI contract tests + //! + //! | Test | What it covers | + //! |---------------------------------------|----------------------------------------| + //! | `help_is_json_control_record` | `--help` is wrapped as JSON metadata | + //! | `version_is_json_control_record` | `--version` is wrapped as JSON metadata| + //! | `usage_error_is_json_control_record` | argv errors become JSON usage records | + + use torrust_index_cli_common::{CommandExit, ControlPlaneFields, ControlPlaneRecordKind, parse_args_from}; + + use super::{Args, COMMAND_NAME}; + + #[test] + fn help_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--help"]) else { + panic!("help should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Help); + + let Some(ControlPlaneFields::Help { text }) = exit.record.fields else { + panic!("help record should carry help text"); + }; + assert!(text.contains("Minimal health-check")); + assert!(text.contains("--debug")); + } + + #[test] + fn version_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--version"]) else { + panic!("version should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Version); + + let Some(ControlPlaneFields::Version { version }) = exit.record.fields else { + panic!("version record should carry version text"); + }; + assert_eq!(version, format!("{COMMAND_NAME} {}", env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn usage_error_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--no-such-flag"]) else { + panic!("unknown flags should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Usage); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::UsageError); + assert!(exit.record.message.contains("--no-such-flag")); + + let Some(ControlPlaneFields::UsageError { + exit_code, + clap_error_kind, + }) = exit.record.fields + else { + panic!("usage record should carry usage fields"); + }; + assert_eq!(exit_code, CommandExit::Usage.code()); + assert_eq!(clap_error_kind, "unknown_argument"); } } diff --git a/src/jwt.rs b/src/jwt.rs index b5c51e1d..3d8e15b7 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -35,8 +35,9 @@ //! //! **Phase 6 — `torrust-index-auth-keypair` CLI.** A standalone binary //! (`torrust-index-auth-keypair`) generates an RSA-2048 key pair -//! and writes a JSON object with both PEM blocks to stdout. The container -//! entry script uses it to auto-generate persistent keys on first boot. +//! and writes a schema-versioned JSON object with both PEM blocks to stdout. +//! The container entry script uses it to auto-generate persistent keys on first +//! boot. //! See `packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs` //! for the binary and ADR-T-007 Phase 6 / ADR-T-009 Phase 2 / ADR-T-010 //! for full context. From e8adbff0d4f949bdfc9d82801cb42bf19319146f Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 11:41:31 +0200 Subject: [PATCH 04/13] feat(cli)!: implement ADR-T-010 root JSON logging boundary Route root application logging through the shared ADR-T-010 JSON stderr tracing setup, using the configured logging threshold as the default filter while preserving non-empty RUST_LOG as the override. Install the shared JSON panic hook and explicit CommandExit mappings at the root server and maintenance-binary entrypoints, and convert the server task boundary from panic/expect paths into JSON-logged failures with explicit exit codes. Update the changelog, README, container docs, and ADR-T-010 rollout plan to document the server JSON stderr contract, the root ExitCode boundary state, and the remaining legacy maintenance-command internals. BREAKING CHANGE: the torrust-index server now emits application logs as JSON records on stderr instead of human-formatted tracing output. --- CHANGELOG.md | 17 ++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 16 +++-- docs/containers.md | 8 +++ ...10-command-line-output-conformance-plan.md | 31 +++++---- src/bin/create_test_torrent.rs | 12 +++- src/bin/import_tracker_statistics.rs | 11 +++- src/bin/parse_torrent.rs | 23 ++++++- src/bin/seeder.rs | 18 +++++- src/bin/upgrade.rs | 10 ++- src/bootstrap/logging.rs | 64 +++++-------------- src/console/commands/seeder/logging.rs | 7 +- src/main.rs | 27 +++++++- 14 files changed, 162 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4fa44f..ba64ee69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ error system (ADR-T-006), MSRV raised to 1.88. stdout-producing commands refuse direct terminal stdout. Existing plain-text root maintenance commands are legacy gaps and will be migrated in later stages. +- The `torrust-index` server's application logs now use JSON records on stderr + instead of the previous human-formatted tracing output. Log consumers should + parse stderr as NDJSON or pipe it through a JSON viewer. - `torrust-index-auth-keypair` and `torrust-index-health-check` stdout JSON now includes a top-level `schema` field. Scripts that expected an exact object shape must tolerate or consume the schema field. @@ -95,10 +98,18 @@ error system (ADR-T-006), MSRV raised to 1.88. panic hook. Their `--help`, `--version`, argv errors, TTY refusal, and panic diagnostics are JSON control-plane records on stderr; stdout remains reserved for successful result JSON. +- Central application logging now delegates to the shared ADR-T-010 JSON stderr + tracing setup. The `torrust-index` server keeps stdout empty for normal + operation, uses the configured logging threshold as its default filter, and + lets a non-empty `RUST_LOG` override that default. +- Root Rust binaries now return explicit `ExitCode` values at their `main` + boundaries and install the shared JSON panic hook. Their command internals may + still contain legacy plain-text output until later ADR-T-010 rollout stages + migrate each command body. - Operator documentation now describes the ADR-T-010 migration state: helper - binaries have the JSON stdout contract, while root maintenance binaries and - the container entry script remain legacy output gaps until their rollout - stages land. + binaries have the JSON stdout contract, the server emits JSON tracing on + stderr, and root maintenance binaries plus the container entry script remain + legacy output gaps until their rollout stages land. ### ADR-T-009 — Container infrastructure refactor diff --git a/Cargo.lock b/Cargo.lock index 3586144b..7b431025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4223,12 +4223,12 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml 1.1.2+spec-1.1.0", + "torrust-index-cli-common", "torrust-index-config", "torrust-index-render-text-as-image", "tower", "tower-http", "tracing", - "tracing-subscriber", "url", "urlencoding", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 907e715d..5ae876fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ opt-level = 3 opt-level = 3 [dependencies] +torrust-index-cli-common = { version = "4.0.0-develop", path = "packages/index-cli-common" } torrust-index-config = { version = "4.0.0-develop", path = "packages/index-config" } torrust-index-render-text-as-image = { version = "0.1.0", path = "packages/render-text-as-image" } @@ -107,7 +108,6 @@ toml = "1" tower = { version = "0", features = ["timeout"] } tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } tracing = "0" -tracing-subscriber = { version = "0", features = ["json"] } url = { version = "2", features = ["serde"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } diff --git a/README.md b/README.md index 21c9b91d..a6fae094 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,12 @@ reserved for machine-readable result data, stderr is reserved for machine-readable diagnostics/control records, and commands that emit stdout result data refuse to write it directly to a terminal. +The `torrust-index` server binary is a no-stdout command. Application tracing is +emitted as JSON records on stderr; the configured `[logging].threshold` selects +the default filter, and a non-empty `RUST_LOG` environment variable overrides +that default. Panics that cross the binary boundary are reported as ADR-T-010 +JSON control-plane records on stderr. + The shared helper infrastructure now wraps `clap` help, version, and usage errors as JSON control-plane records on stderr, installs a JSON-only panic hook, and uses JSON tracing on stderr. The container helper binaries emit exactly one @@ -188,10 +194,12 @@ filter to debug. The older root maintenance binaries (`parse_torrent`, `create_test_torrent`, `import_tracker_statistics`, `seeder`, and `upgrade`) are in ADR-T-010 scope but -are still migration targets. Do not build new automation around their current -plain-text output; the target contract for side-effect commands is empty stdout -and JSON diagnostics on stderr, while `parse_torrent` will become a JSON stdout -result command. +are still migration targets. Their `main` boundaries now use explicit exit codes +and the shared JSON panic hook, but their command bodies may still emit legacy +plain text until their later rollout stages land. Do not build new automation +around their current plain-text output; the target contract for side-effect +commands is empty stdout and JSON diagnostics on stderr, while `parse_torrent` +will become a JSON stdout result command. ## Documentation diff --git a/docs/containers.md b/docs/containers.md index bba454c6..a0570985 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -407,6 +407,12 @@ failure rather than silent misbehaviour. ### Command-Line Output Contract +The `torrust-index` server process is a no-stdout command. Once the Rust +application starts, its tracing diagnostics are JSON records on stderr. The +configured `[logging].threshold` selects the default filter, and a non-empty +`RUST_LOG` environment variable overrides that default. The server does not +refuse terminal stdout, because it does not emit stdout result data. + Container helper binaries follow the ADR-T-010 stdout/stderr split. When they emit result data, stdout is exactly one JSON object with a trailing newline and a top-level `schema` field. Diagnostics, help, version output, argv errors, TTY @@ -437,6 +443,8 @@ The container entry script captures helper stdout internally and does not forward it to the terminal. Its own diagnostics are still part of the ADR-T-010 migration backlog; until that rollout stage lands, treat any plain-text entry script stderr as a legacy compatibility gap rather than a new output contract. +Container startup logs may therefore contain legacy entry-script lines before +the Rust server begins emitting JSON tracing records. ### Healthcheck (both targets) diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index 2fae2fc3..81bae464 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -11,7 +11,8 @@ code, documentation, and regression tests. ## Current Implementation Status -Stages 1 through 3 have landed for the shared Rust helper path: +Stages 1 through 4 have landed for the shared Rust helper path and root command +boundaries: - Stage 1 fixed the shared control-plane record shape, baseline exit classes, helper stdout schemas, and redaction helpers. @@ -23,9 +24,14 @@ Stages 1 through 3 have landed for the shared Rust helper path: `torrust-index-health-check` to the expanded shared infrastructure. Their help, version, argv errors, TTY refusal, and panic diagnostics are now JSON control-plane records on stderr. +- Stage 4 switched central application logging to the shared JSON stderr + tracing setup, added the shared CLI contract crate to the root package, and + made root binaries return explicit `ExitCode` values at their `main` + boundaries. The maintenance-command internals remain legacy output gaps until + their later migration stages land. -The root server, root maintenance binaries, and container entry script remain -future rollout stages unless their sections below say otherwise. +The root maintenance command internals and container entry script remain future +rollout stages unless their sections below say otherwise. ## Goal @@ -448,11 +454,12 @@ Required changes: Update operator documentation after the behavior changes. Current documentation status: the shared contract shape, helper stdout result -schemas, expanded Rust CLI infrastructure, and helper-binary wiring state have -been documented. Root maintenance commands and the container entry script are -still legacy output gaps until their rollout stages land; their documentation -should describe the ADR-T-010 target contract without promising behaviour the -binaries do not yet implement. +schemas, expanded Rust CLI infrastructure, helper-binary wiring state, and +stage-four server logging / root `ExitCode` boundary state have been documented. +Root maintenance command internals and the container entry script are still +legacy output gaps until their rollout stages land; their documentation should +describe the ADR-T-010 target contract without promising behaviour the binaries +do not yet implement. Required changes: @@ -524,10 +531,10 @@ summarizing results, following the repository test-running convention. ## Rollout Order -Current status: steps 1 through 3 have landed. The documentation for those -stages has been updated as part of the stage-two/three rollout; the later -operator-visible migrations still need their own documentation and changelog -updates when they land. +Current status: steps 1 through 4 have landed. The documentation for the +shared-helper stages and the stage-four root logging / binary-boundary rollout +has been updated; the later operator-visible migrations still need their own +documentation and changelog updates when they land. 1. Finalize the shared control-plane record shape, command-specific result schema details, exit-code mapping, and redaction rules. diff --git a/src/bin/create_test_torrent.rs b/src/bin/create_test_torrent.rs index e0524ac5..4ee4811a 100644 --- a/src/bin/create_test_torrent.rs +++ b/src/bin/create_test_torrent.rs @@ -7,19 +7,25 @@ use std::env; use std::fs::File; use std::io::Write; use std::path::Path; +use std::process::ExitCode; use torrust_index::models::torrent_file::{Torrent, TorrentFile, TorrentInfoDictionary}; use torrust_index::services::hasher::sha1; // DevSkim: ignore DS126858 use torrust_index::utils::parse_torrent; +use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; use uuid::Uuid; -fn main() { +const COMMAND_NAME: &str = "create_test_torrent"; + +fn main() -> ExitCode { + install_json_panic_hook(COMMAND_NAME); + let args: Vec = env::args().collect(); if args.len() != 2 { eprintln!("Usage: cargo run --bin create_test_torrent "); eprintln!("Example: cargo run --bin create_test_torrent ./output/test/torrents"); - std::process::exit(1); + return CommandExit::Usage.exit_code(); } let destination_folder = &args[1]; @@ -71,4 +77,6 @@ fn main() { } Err(e) => panic!("Error encoding torrent: {e}"), } + + CommandExit::Success.exit_code() } diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index bf3056c5..04f35fc6 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -7,9 +7,18 @@ //! ADR-T-010 classifies this as a side-effect command: the target contract is //! empty stdout and JSON diagnostics on stderr. The current implementation is a //! legacy output gap until migration. +use std::process::ExitCode; + use torrust_index::console::commands::tracker_statistics_importer::app::run; +use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; + +const COMMAND_NAME: &str = "import_tracker_statistics"; #[tokio::main] -async fn main() { +async fn main() -> ExitCode { + install_json_panic_hook(COMMAND_NAME); + run().await; + + CommandExit::Success.exit_code() } diff --git a/src/bin/parse_torrent.rs b/src/bin/parse_torrent.rs index d2073aea..a79dc566 100644 --- a/src/bin/parse_torrent.rs +++ b/src/bin/parse_torrent.rs @@ -7,19 +7,36 @@ use std::env; use std::fs::File; use std::io::{self, Read}; +use std::process::ExitCode; use serde_bencode::de::from_bytes; use serde_bencode::value::Value as BValue; use torrust_index::utils::parse_torrent; +use torrust_index_cli_common::{CommandExit, ControlPlaneRecord, emit_control_plane_record, install_json_panic_hook}; -fn main() -> io::Result<()> { +const COMMAND_NAME: &str = "parse_torrent"; + +fn main() -> ExitCode { + install_json_panic_hook(COMMAND_NAME); + + match run() { + Ok(exit) => exit.exit_code(), + Err(error) => { + let record = ControlPlaneRecord::diagnostic(COMMAND_NAME, &error.to_string()); + let _ignored = emit_control_plane_record(&record); + CommandExit::Failure.exit_code() + } + } +} + +fn run() -> io::Result { let args: Vec = env::args().collect(); if args.len() != 2 { eprintln!("Usage: cargo run --bin parse_torrent "); eprintln!( "Example: cargo run --bin parse_torrent ./tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent" ); - std::process::exit(1); + return Ok(CommandExit::Usage); } println!("Reading the torrent file ..."); @@ -34,7 +51,7 @@ fn main() -> io::Result<()> { Ok(_value) => match parse_torrent::decode_torrent(&bytes) { Ok(torrent) => { println!("Parsed torrent: \n{torrent:#?}"); - Ok(()) + Ok(CommandExit::Success) } Err(e) => Err(io::Error::other(format!("Error: invalid torrent!. {e}"))), }, diff --git a/src/bin/seeder.rs b/src/bin/seeder.rs index f5cc93c7..9f75d774 100644 --- a/src/bin/seeder.rs +++ b/src/bin/seeder.rs @@ -3,9 +3,23 @@ //! ADR-T-010 classifies this as a side-effect command: the target contract is //! empty stdout and JSON diagnostics on stderr. The current implementation is a //! legacy output gap until migration. +use std::process::ExitCode; + use torrust_index::console::commands::seeder::app; +use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; +use tracing::error; + +const COMMAND_NAME: &str = "seeder"; #[tokio::main] -async fn main() -> Result<(), Box> { - app::run().await +async fn main() -> ExitCode { + install_json_panic_hook(COMMAND_NAME); + + match app::run().await { + Ok(()) => CommandExit::Success.exit_code(), + Err(error) => { + error!(%error, "command failed"); + CommandExit::Failure.exit_code() + } + } } diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index aafa69d1..84e1c510 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -5,10 +5,18 @@ //! ADR-T-010 classifies this as a side-effect command: the target contract is //! empty stdout and JSON diagnostics on stderr. The current implementation is a //! legacy output gap until migration. +use std::process::ExitCode; use torrust_index::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run; +use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; + +const COMMAND_NAME: &str = "upgrade"; #[tokio::main] -async fn main() { +async fn main() -> ExitCode { + install_json_panic_hook(COMMAND_NAME); + run().await; + + CommandExit::Success.exit_code() } diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs index 699b0380..a0e552ea 100644 --- a/src/bootstrap/logging.rs +++ b/src/bootstrap/logging.rs @@ -1,66 +1,32 @@ //! Setup for the application logging. -//! -//! - `Off` -//! - `Error` -//! - `Warn` -//! - `Info` -//! - `Debug` -//! - `Trace` -use std::sync::Once; - -use tracing::info; +use torrust_index_cli_common::init_json_tracing; use tracing::level_filters::LevelFilter; +use tracing::{Level, info}; use crate::config::Threshold; -static INIT: Once = Once::new(); - pub fn setup(threshold: &Threshold) { let tracing_level_filter: LevelFilter = threshold.clone().into(); - if tracing_level_filter == LevelFilter::OFF { - return; - } - - INIT.call_once(|| { - tracing_stdout_init(tracing_level_filter, &TraceStyle::Default); - }); + setup_level_filter(tracing_level_filter); } -fn tracing_stdout_init(filter: LevelFilter, style: &TraceStyle) { - let builder = tracing_subscriber::fmt().with_max_level(filter); - - let () = match style { - TraceStyle::Default => builder.init(), - TraceStyle::Pretty(display_filename) => builder.pretty().with_file(*display_filename).init(), - TraceStyle::Compact => builder.compact().init(), - TraceStyle::Json => builder.json().init(), +pub fn setup_level_filter(filter: LevelFilter) { + let Some(level) = level_from_filter(filter) else { + return; }; + init_json_tracing(level); info!("Logging initialized"); } -#[derive(Debug)] -pub enum TraceStyle { - Default, - Pretty(bool), - Compact, - Json, -} - -impl std::fmt::Display for TraceStyle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let style = match self { - Self::Default => "Default Style", - Self::Pretty(path) => match path { - true => "Pretty Style with File Paths", - false => "Pretty Style without File Paths", - }, - - Self::Compact => "Compact Style", - Self::Json => "Json Format", - }; - - f.write_str(style) +fn level_from_filter(filter: LevelFilter) -> Option { + match filter { + LevelFilter::OFF => None, + LevelFilter::ERROR => Some(Level::ERROR), + LevelFilter::WARN => Some(Level::WARN), + LevelFilter::INFO => Some(Level::INFO), + LevelFilter::DEBUG => Some(Level::DEBUG), + LevelFilter::TRACE => Some(Level::TRACE), } } diff --git a/src/console/commands/seeder/logging.rs b/src/console/commands/seeder/logging.rs index 0f9a9afc..6763abbc 100644 --- a/src/console/commands/seeder/logging.rs +++ b/src/console/commands/seeder/logging.rs @@ -1,12 +1,11 @@ //! Logging setup for the `seeder`. -use tracing::debug; use tracing::level_filters::LevelFilter; +use crate::bootstrap::logging; + /// # Panics /// /// pub fn setup(level: LevelFilter) { - tracing_subscriber::fmt().with_max_level(level).init(); - - debug!("Logging initialized"); + logging::setup_level_filter(level); } diff --git a/src/main.rs b/src/main.rs index 387c8888..6b8193d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,39 @@ +use std::process::ExitCode; + use torrust_index::app; use torrust_index::bootstrap::config::initialize_configuration; use torrust_index::web::api::Version; +use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; +use tracing::error; + +const COMMAND_NAME: &str = "torrust-index"; #[tokio::main] -async fn main() -> Result<(), std::io::Error> { +async fn main() -> ExitCode { + install_json_panic_hook(COMMAND_NAME); + let configuration = initialize_configuration(); let api_version = Version::V1; let app = app::run(configuration, &api_version).await; - assert!(!app.api_server_halt_task.is_closed(), "Halt channel should be open"); + if app.api_server_halt_task.is_closed() { + error!("halt channel closed before server shutdown"); + return CommandExit::Failure.exit_code(); + } match api_version { - Version::V1 => app.api_server.await.expect("the API server was dropped"), + Version::V1 => match app.api_server.await { + Ok(Ok(())) => CommandExit::Success.exit_code(), + Ok(Err(error)) => { + error!(%error, "API server failed"); + CommandExit::Failure.exit_code() + } + Err(error) => { + error!(%error, "API server task was dropped"); + CommandExit::Failure.exit_code() + } + }, } } From b5332e13567b4fbbdb3891e72d68530d7ecdabaa Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 12:01:59 +0200 Subject: [PATCH 05/13] feat(cli)!: migrate torrent helpers to ADR-T-010 Move parse_torrent and create_test_torrent from hand-rolled argv handling and plain-text diagnostics onto the shared ADR-T-010 CLI helpers for JSON clap records, JSON panic reporting, and stdout/stderr command runners. Make parse_torrent emit a schema-versioned JSON stdout result with the decoded torrent, original v1 info hash, and input byte length, while routing failures and terminal-stdout refusal through JSON stderr control-plane records. Make create_test_torrent a no-stdout side-effect command that writes the torrent file to the requested directory, reports the generated path through JSON stderr tracing, and converts encode and file I/O failures into explicit diagnostics. Remove stray parse_torrent utility print paths, add focused CLI contract tests, and update the README, changelog, and ADR-T-010 rollout plan for the stage-five root command migration. BREAKING CHANGE: parse_torrent and create_test_torrent now follow the ADR-T-010 JSON stdout/stderr contracts instead of their legacy plain-text CLI behavior. --- CHANGELOG.md | 27 ++- README.md | 33 ++- ...10-command-line-output-conformance-plan.md | 39 ++-- src/bin/create_test_torrent.rs | 183 +++++++++++++--- src/bin/parse_torrent.rs | 202 ++++++++++++++---- src/bootstrap/logging.rs | 2 +- src/utils/parse_torrent.rs | 16 +- 7 files changed, 382 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba64ee69..3c8a4e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,11 @@ error system (ADR-T-006), MSRV raised to 1.88. - First-party command-line entrypoints are now governed by ADR-T-010's JSON-only output contract. Stdout is reserved for machine-readable result data, stderr is reserved for machine-readable diagnostics/control records, and - stdout-producing commands refuse direct terminal stdout. Existing plain-text - root maintenance commands are legacy gaps and will be migrated in later - stages. + stdout-producing commands refuse direct terminal stdout. `parse_torrent` now + emits JSON result data on stdout, and `create_test_torrent` now keeps stdout + empty while reporting status and diagnostics as JSON on stderr. Remaining + plain-text root maintenance command bodies are legacy gaps and will be + migrated in later stages. - The `torrust-index` server's application logs now use JSON records on stderr instead of the previous human-formatted tracing output. Log consumers should parse stderr as NDJSON or pipe it through a JSON viewer. @@ -103,12 +105,23 @@ error system (ADR-T-006), MSRV raised to 1.88. operation, uses the configured logging threshold as its default filter, and lets a non-empty `RUST_LOG` override that default. - Root Rust binaries now return explicit `ExitCode` values at their `main` - boundaries and install the shared JSON panic hook. Their command internals may - still contain legacy plain-text output until later ADR-T-010 rollout stages - migrate each command body. + boundaries and install the shared JSON panic hook. `parse_torrent` and + `create_test_torrent` now use the shared JSON `clap` parser, JSON stderr + tracing runners, and focused CLI contract tests. +- `parse_torrent` now emits one JSON stdout result object with `schema`, + `torrent`, `original_v1_info_hash`, and `input_byte_length`, leaves stdout + empty on failure, and refuses direct terminal stdout with a JSON stderr + control-plane record. +- `create_test_torrent` now keeps stdout empty, reports the generated torrent + path as a JSON status record on stderr, and converts argument, encode, file + creation, and write failures into JSON diagnostics with explicit exit codes. +- Remaining root maintenance command internals may still contain legacy + plain-text output until later ADR-T-010 rollout stages migrate each command + body. - Operator documentation now describes the ADR-T-010 migration state: helper binaries have the JSON stdout contract, the server emits JSON tracing on - stderr, and root maintenance binaries plus the container entry script remain + stderr, stage-five root commands have their JSON stream contract, and the + remaining root maintenance binaries plus the container entry script remain legacy output gaps until their rollout stages land. ### ADR-T-009 — Container infrastructure refactor diff --git a/README.md b/README.md index a6fae094..ad04c712 100644 --- a/README.md +++ b/README.md @@ -192,14 +192,31 @@ For helper diagnostics, a non-empty `RUST_LOG` environment variable takes precedence over `--debug`; otherwise `--debug` raises the default diagnostic filter to debug. -The older root maintenance binaries (`parse_torrent`, `create_test_torrent`, -`import_tracker_statistics`, `seeder`, and `upgrade`) are in ADR-T-010 scope but -are still migration targets. Their `main` boundaries now use explicit exit codes -and the shared JSON panic hook, but their command bodies may still emit legacy -plain text until their later rollout stages land. Do not build new automation -around their current plain-text output; the target contract for side-effect -commands is empty stdout and JSON diagnostics on stderr, while `parse_torrent` -will become a JSON stdout result command. +Two root diagnostic commands have also been migrated. `parse_torrent` is a +stdout-result command: it emits one JSON object containing `schema`, `torrent`, +`original_v1_info_hash`, and `input_byte_length`, and it refuses direct terminal +stdout. Pipe or redirect it before inspection: + +```sh +fixture=./tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent +cargo run --quiet --bin parse_torrent -- "$fixture" | jq . +``` + +`create_test_torrent` is a no-stdout side-effect command. It writes the torrent +file into an existing destination directory, keeps stdout empty, and emits JSON +status or diagnostic records on stderr: + +```sh +mkdir -p ./output/test/torrents +cargo run --quiet --bin create_test_torrent -- ./output/test/torrents 2>create-test-torrent.ndjson +jq . create-test-torrent.ndjson +``` + +The remaining root maintenance binaries (`import_tracker_statistics`, `seeder`, +and `upgrade`) are still ADR-T-010 migration targets. Their `main` boundaries +use explicit exit codes and the shared JSON panic hook, but their command bodies +may still emit legacy plain text until their later rollout stages land. Do not +build new automation around their current plain-text output. ## Documentation diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index 81bae464..8f949488 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -11,8 +11,8 @@ code, documentation, and regression tests. ## Current Implementation Status -Stages 1 through 4 have landed for the shared Rust helper path and root command -boundaries: +Stages 1 through 5 have landed for the shared Rust helper path and initial root +command migrations: - Stage 1 fixed the shared control-plane record shape, baseline exit classes, helper stdout schemas, and redaction helpers. @@ -27,11 +27,16 @@ boundaries: - Stage 4 switched central application logging to the shared JSON stderr tracing setup, added the shared CLI contract crate to the root package, and made root binaries return explicit `ExitCode` values at their `main` - boundaries. The maintenance-command internals remain legacy output gaps until - their later migration stages land. + boundaries. At that point, maintenance-command internals still remained + legacy output gaps pending their per-command migration stages. +- Stage 5 migrated `parse_torrent` and `create_test_torrent` to the shared JSON + clap parser, JSON panic hook, JSON stderr tracing runners, and focused CLI + contract tests. `parse_torrent` now emits one JSON stdout result object and + refuses terminal stdout; `create_test_torrent` remains a no-stdout side-effect + command. -The root maintenance command internals and container entry script remain future -rollout stages unless their sections below say otherwise. +The remaining root maintenance command internals and container entry script +remain future rollout stages unless their sections below say otherwise. ## Goal @@ -351,6 +356,10 @@ Required changes: Update every root maintenance and diagnostic binary. +Stage 5 status: implemented for `src/bin/parse_torrent.rs` and +`src/bin/create_test_torrent.rs`. The remaining root maintenance binaries are +left for Stage 6. + Required changes for `src/bin/parse_torrent.rs`: - Replace hand-rolled `std::env::args()` parsing with clap plus the shared JSON @@ -456,10 +465,11 @@ Update operator documentation after the behavior changes. Current documentation status: the shared contract shape, helper stdout result schemas, expanded Rust CLI infrastructure, helper-binary wiring state, and stage-four server logging / root `ExitCode` boundary state have been documented. -Root maintenance command internals and the container entry script are still -legacy output gaps until their rollout stages land; their documentation should -describe the ADR-T-010 target contract without promising behaviour the binaries -do not yet implement. +The stage-five `parse_torrent` and `create_test_torrent` migration has also been +documented. Remaining root maintenance command internals and the container entry +script are still legacy output gaps until their rollout stages land; their +documentation should describe the ADR-T-010 target contract without promising +behaviour the binaries do not yet implement. Required changes: @@ -531,10 +541,11 @@ summarizing results, following the repository test-running convention. ## Rollout Order -Current status: steps 1 through 4 have landed. The documentation for the -shared-helper stages and the stage-four root logging / binary-boundary rollout -has been updated; the later operator-visible migrations still need their own -documentation and changelog updates when they land. +Current status: steps 1 through 5 have landed. The documentation for the +shared-helper stages, the stage-four root logging / binary-boundary rollout, and +the initial stage-five root binary migration has been updated; the later +operator-visible migrations still need their own documentation and changelog +updates when they land. 1. Finalize the shared control-plane record shape, command-specific result schema details, exit-code mapping, and redaction rules. diff --git a/src/bin/create_test_torrent.rs b/src/bin/create_test_torrent.rs index 4ee4811a..1790773a 100644 --- a/src/bin/create_test_torrent.rs +++ b/src/bin/create_test_torrent.rs @@ -1,41 +1,64 @@ //! Command line tool to create a test torrent file. -//! -//! It's only used for debugging purposes. ADR-T-010 classifies it as a -//! side-effect command: the target contract is empty stdout and JSON diagnostics -//! on stderr. The current implementation is a legacy output gap until migration. -use std::env; + use std::fs::File; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::ExitCode; +use clap::Parser; +use thiserror::Error; use torrust_index::models::torrent_file::{Torrent, TorrentFile, TorrentInfoDictionary}; use torrust_index::services::hasher::sha1; // DevSkim: ignore DS126858 use torrust_index::utils::parse_torrent; -use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; +use torrust_index_cli_common::{BaseArgs, install_json_panic_hook, parse_args_or_exit, run_no_stdout_command}; +use tracing::info; use uuid::Uuid; const COMMAND_NAME: &str = "create_test_torrent"; +#[derive(Parser)] +#[command(name = "create_test_torrent", version, about = "Create a test torrent file")] +struct Args { + /// Directory where the generated torrent file will be written. + destination_folder: PathBuf, + + #[command(flatten)] + base: BaseArgs, +} + +#[derive(Debug, Error)] +enum CommandError { + #[error("generated file content is too large to fit in a torrent length field")] + ContentTooLarge, + + #[error("failed to encode torrent: {source}")] + EncodeTorrent { source: serde_bencode::Error }, + + #[error("failed to create torrent file: {source}")] + CreateFile { source: std::io::Error }, + + #[error("failed to write torrent file: {source}")] + WriteFile { source: std::io::Error }, +} + fn main() -> ExitCode { install_json_panic_hook(COMMAND_NAME); - let args: Vec = env::args().collect(); + let args = parse_args_or_exit::(); - if args.len() != 2 { - eprintln!("Usage: cargo run --bin create_test_torrent "); - eprintln!("Example: cargo run --bin create_test_torrent ./output/test/torrents"); - return CommandExit::Usage.exit_code(); - } - - let destination_folder = &args[1]; + run_no_stdout_command::(COMMAND_NAME, args.base.debug, tracing::Level::INFO, || { + create_test_torrent(&args.destination_folder) + }) +} +fn create_test_torrent(destination_folder: &Path) -> Result<(), CommandError> { let id = Uuid::new_v4(); // Content of the file from which the torrent will be generated. // We use the UUID as the content of the file. let file_contents = format!("{id}\n"); let file_name = format!("file-{id}.txt"); + let file_length = i64::try_from(file_contents.len()).map_err(|_error| CommandError::ContentTooLarge)?; let torrent = Torrent { info: TorrentInfoDictionary::with( @@ -46,7 +69,7 @@ fn main() -> ExitCode { &sha1(&file_contents), // DevSkim: ignore DS126858 &[TorrentFile { path: vec![file_name.clone()], // Adjusted to include the actual file name - length: i64::try_from(file_contents.len()).expect("file contents size in bytes cannot exceed i64::MAX"), + length: file_length, md5sum: None, // DevSkim: ignore DS126858 }], ), @@ -60,23 +83,117 @@ fn main() -> ExitCode { created_by: None, }; - match parse_torrent::encode_torrent(&torrent) { - Ok(bytes) => { - // Construct the path where the torrent file will be saved - let file_path = Path::new(destination_folder).join(format!("{file_name}.torrent")); - - // Attempt to create and write to the file - let mut file = match File::create(&file_path) { - Ok(file) => file, - Err(e) => panic!("Failed to create file {}: {e}", file_path.display()), - }; - - if let Err(e) = file.write_all(&bytes) { - panic!("Failed to write to file {}: {e}", file_path.display()); - } - } - Err(e) => panic!("Error encoding torrent: {e}"), + let bytes = parse_torrent::encode_torrent(&torrent).map_err(|source| CommandError::EncodeTorrent { source })?; + let torrent_file_path = destination_folder.join(format!("{file_name}.torrent")); + let mut output_file = File::create(&torrent_file_path).map_err(|source| CommandError::CreateFile { source })?; + output_file + .write_all(&bytes) + .map_err(|source| CommandError::WriteFile { source })?; + + info!(path = %torrent_file_path.display(), "created test torrent file"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + //! # Create-test-torrent binary CLI contract tests + //! + //! | Test | What it covers | + //! |---------------------------------------|----------------------------------------| + //! | `help_is_json_control_record` | `--help` is wrapped as JSON metadata | + //! | `version_is_json_control_record` | `--version` is wrapped as JSON metadata| + //! | `usage_error_is_json_control_record` | argv errors become JSON usage records | + //! | `command_writes_valid_torrent_file` | side effect succeeds without stdout data| + //! | `missing_directory_yields_error` | file creation errors are propagated | + + use tempfile::TempDir; + use torrust_index::utils::parse_torrent::decode_and_validate_torrent_file; + use torrust_index_cli_common::{CommandExit, ControlPlaneFields, ControlPlaneRecordKind, parse_args_from}; + + use super::{Args, COMMAND_NAME, create_test_torrent}; + + #[test] + fn help_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--help"]) else { + panic!("help should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Help); + + let Some(ControlPlaneFields::Help { text }) = exit.record.fields else { + panic!("help record should carry help text"); + }; + assert!(text.contains("Create a test torrent file")); + assert!(text.contains("--debug")); + } + + #[test] + fn version_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--version"]) else { + panic!("version should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Version); + + let Some(ControlPlaneFields::Version { version }) = exit.record.fields else { + panic!("version record should carry version text"); + }; + assert_eq!(version, format!("{COMMAND_NAME} {}", env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn usage_error_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--no-such-flag"]) else { + panic!("unknown flags should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Usage); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::UsageError); + assert!(exit.record.message.contains("--no-such-flag")); + + let Some(ControlPlaneFields::UsageError { + exit_code, + clap_error_kind, + }) = exit.record.fields + else { + panic!("usage record should carry usage fields"); + }; + assert_eq!(exit_code, CommandExit::Usage.code()); + assert_eq!(clap_error_kind, "unknown_argument"); } - CommandExit::Success.exit_code() + #[test] + fn command_writes_valid_torrent_file() { + let directory = TempDir::new().expect("temporary directory must be created"); + + create_test_torrent(directory.path()).expect("torrent creation must succeed"); + + let mut entries = std::fs::read_dir(directory.path()).expect("temporary directory must be readable"); + let entry = entries + .next() + .expect("one torrent file must be written") + .expect("directory entry must be readable"); + assert!(entries.next().is_none(), "only one torrent file should be written"); + assert_eq!(entry.path().extension().and_then(std::ffi::OsStr::to_str), Some("torrent")); + + let bytes = std::fs::read(entry.path()).expect("torrent file must be readable"); + let (_torrent, original_info_hash) = decode_and_validate_torrent_file(&bytes).expect("written torrent must decode"); + assert_eq!(original_info_hash.to_hex_string().len(), 40); + } + + #[test] + fn missing_directory_yields_error() { + let directory = TempDir::new().expect("temporary directory must be created"); + let missing_directory = directory.path().join("missing"); + + let error = create_test_torrent(&missing_directory).expect_err("missing output directory must fail"); + + assert!(error.to_string().contains("failed to create torrent file")); + } } diff --git a/src/bin/parse_torrent.rs b/src/bin/parse_torrent.rs index a79dc566..131c2ed4 100644 --- a/src/bin/parse_torrent.rs +++ b/src/bin/parse_torrent.rs @@ -1,60 +1,176 @@ -//! Command line tool to parse a torrent file and print the decoded torrent. -//! -//! It's only used for debugging purposes. ADR-T-010 classifies it as a stdout -//! result-data command; after migration it will emit one JSON object on stdout, -//! refuse direct terminal stdout, and report diagnostics on stderr as JSON. The -//! current implementation is a legacy output gap until migration. -use std::env; -use std::fs::File; -use std::io::{self, Read}; +//! Command line tool to parse a torrent file and emit the decoded torrent. + +use std::path::{Path, PathBuf}; use std::process::ExitCode; -use serde_bencode::de::from_bytes; -use serde_bencode::value::Value as BValue; -use torrust_index::utils::parse_torrent; -use torrust_index_cli_common::{CommandExit, ControlPlaneRecord, emit_control_plane_record, install_json_panic_hook}; +use clap::Parser; +use serde::Serialize; +use thiserror::Error; +use torrust_index::models::torrent_file::Torrent; +use torrust_index::utils::parse_torrent::{DecodeTorrentFileError, decode_and_validate_torrent_file}; +use torrust_index_cli_common::{BaseArgs, install_json_panic_hook, parse_args_or_exit, run_stdout_json_command}; const COMMAND_NAME: &str = "parse_torrent"; +const OUTPUT_SCHEMA: u32 = 1; + +#[derive(Parser)] +#[command( + name = "parse_torrent", + version, + about = "Parse a torrent file and emit decoded torrent metadata as JSON" +)] +struct Args { + /// Path to the torrent file to parse. + torrent_file: PathBuf, + + #[command(flatten)] + base: BaseArgs, +} + +#[derive(Debug, Serialize)] +struct Output { + schema: u32, + torrent: Torrent, + original_v1_info_hash: String, + input_byte_length: usize, +} + +#[derive(Debug, Error)] +enum CommandError { + #[error("failed to read torrent file: {source}")] + ReadInput { source: std::io::Error }, + + #[error("failed to decode torrent file: {source}")] + DecodeInput { source: DecodeTorrentFileError }, +} fn main() -> ExitCode { install_json_panic_hook(COMMAND_NAME); - match run() { - Ok(exit) => exit.exit_code(), - Err(error) => { - let record = ControlPlaneRecord::diagnostic(COMMAND_NAME, &error.to_string()); - let _ignored = emit_control_plane_record(&record); - CommandExit::Failure.exit_code() - } - } + let args = parse_args_or_exit::(); + + run_stdout_json_command::(COMMAND_NAME, args.base.debug, tracing::Level::INFO, || { + parse_file(&args.torrent_file) + }) +} + +fn parse_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|source| CommandError::ReadInput { source })?; + let input_byte_length = bytes.len(); + let (torrent, original_info_hash) = + decode_and_validate_torrent_file(&bytes).map_err(|source| CommandError::DecodeInput { source })?; + + Ok(Output { + schema: OUTPUT_SCHEMA, + torrent, + original_v1_info_hash: original_info_hash.to_hex_string(), + input_byte_length, + }) } -fn run() -> io::Result { - let args: Vec = env::args().collect(); - if args.len() != 2 { - eprintln!("Usage: cargo run --bin parse_torrent "); - eprintln!( - "Example: cargo run --bin parse_torrent ./tests/fixtures/torrents/MC_GRID.zip-3cd18ff2d3eec881207dcc5ca5a2c3a2a3afe462.torrent" - ); - return Ok(CommandExit::Usage); +#[cfg(test)] +mod tests { + //! # Parse-torrent binary CLI contract tests + //! + //! | Test | What it covers | + //! |---------------------------------------|----------------------------------------| + //! | `help_is_json_control_record` | `--help` is wrapped as JSON metadata | + //! | `version_is_json_control_record` | `--version` is wrapped as JSON metadata| + //! | `usage_error_is_json_control_record` | argv errors become JSON usage records | + //! | `valid_torrent_yields_json_result` | stdout result schema and metadata | + //! | `invalid_torrent_yields_error` | invalid input fails before stdout data | + + use std::io::Write; + + use serde_json::Value; + use tempfile::NamedTempFile; + use torrust_index_cli_common::{CommandExit, ControlPlaneFields, ControlPlaneRecordKind, parse_args_from}; + + use super::{Args, COMMAND_NAME, OUTPUT_SCHEMA, parse_file}; + + const VALID_TORRENT_BYTES: &[u8] = include_bytes!( + "../../tests/fixtures/torrents/6c690018c5786dbbb00161f62b0712d69296df97_with_custom_info_dict_key.torrent" + ); + + #[test] + fn help_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--help"]) else { + panic!("help should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Help); + + let Some(ControlPlaneFields::Help { text }) = exit.record.fields else { + panic!("help record should carry help text"); + }; + assert!(text.contains("Parse a torrent file")); + assert!(text.contains("--debug")); + } + + #[test] + fn version_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--version"]) else { + panic!("version should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Success); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::Version); + + let Some(ControlPlaneFields::Version { version }) = exit.record.fields else { + panic!("version record should carry version text"); + }; + assert_eq!(version, format!("{COMMAND_NAME} {}", env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn usage_error_is_json_control_record() { + let Err(exit) = parse_args_from::([COMMAND_NAME, "--no-such-flag"]) else { + panic!("unknown flags should stop parsing"); + }; + + assert_eq!(exit.exit, CommandExit::Usage); + assert_eq!(exit.record.command, COMMAND_NAME); + assert_eq!(exit.record.kind, ControlPlaneRecordKind::UsageError); + assert!(exit.record.message.contains("--no-such-flag")); + + let Some(ControlPlaneFields::UsageError { + exit_code, + clap_error_kind, + }) = exit.record.fields + else { + panic!("usage record should carry usage fields"); + }; + assert_eq!(exit_code, CommandExit::Usage.code()); + assert_eq!(clap_error_kind, "unknown_argument"); } - println!("Reading the torrent file ..."); + #[test] + fn valid_torrent_yields_json_result() { + let mut file = NamedTempFile::new().expect("temporary file must be created"); + file.write_all(VALID_TORRENT_BYTES).expect("fixture torrent must be writable"); + + let output = parse_file(file.path()).expect("fixture torrent must parse"); + let json = serde_json::to_string(&output).expect("parse output must serialize"); + let value: Value = serde_json::from_str(&json).expect("parse output must be valid JSON"); + + assert_eq!(value["schema"], OUTPUT_SCHEMA); + assert_eq!(value["original_v1_info_hash"], "6c690018c5786dbbb00161f62b0712d69296df97"); + assert!(value["input_byte_length"].as_u64().is_some_and(|length| length > 0)); + assert!(value["torrent"].is_object()); + assert!(value.get("path").is_none(), "stdout schema must not expose the input path"); + } - let mut file = File::open(&args[1])?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; + #[test] + fn invalid_torrent_yields_error() { + let mut file = NamedTempFile::new().expect("temporary file must be created"); + file.write_all(b"not bencoded torrent data") + .expect("temporary file must be writable"); - println!("Decoding torrent with standard serde implementation ..."); + let error = parse_file(file.path()).expect_err("invalid torrent data must fail"); - match from_bytes::(&bytes) { - Ok(_value) => match parse_torrent::decode_torrent(&bytes) { - Ok(torrent) => { - println!("Parsed torrent: \n{torrent:#?}"); - Ok(CommandExit::Success) - } - Err(e) => Err(io::Error::other(format!("Error: invalid torrent!. {e}"))), - }, - Err(e) => Err(io::Error::other(format!("Error: invalid bencode data!. {e}"))), + assert!(error.to_string().contains("failed to decode torrent file")); } } diff --git a/src/bootstrap/logging.rs b/src/bootstrap/logging.rs index a0e552ea..13912f12 100644 --- a/src/bootstrap/logging.rs +++ b/src/bootstrap/logging.rs @@ -20,7 +20,7 @@ pub fn setup_level_filter(filter: LevelFilter) { info!("Logging initialized"); } -fn level_from_filter(filter: LevelFilter) -> Option { +const fn level_from_filter(filter: LevelFilter) -> Option { match filter { LevelFilter::OFF => None, LevelFilter::ERROR => Some(Level::ERROR), diff --git a/src/utils/parse_torrent.rs b/src/utils/parse_torrent.rs index 1a0a3fc9..62ffba38 100644 --- a/src/utils/parse_torrent.rs +++ b/src/utils/parse_torrent.rs @@ -58,13 +58,7 @@ pub fn decode_and_validate_torrent_file(bytes: &[u8]) -> Result<(Torrent, InfoHa /// /// This function will return an error if unable to parse bytes into torrent. pub fn decode_torrent(bytes: &[u8]) -> Result> { - match de::from_bytes::(bytes) { - Ok(torrent) => Ok(torrent), - Err(e) => { - println!("{e:?}"); - Err(e.into()) - } - } + de::from_bytes::(bytes).map_err(Into::into) } /// Encode a Torrent into Bencoded Bytes. @@ -73,13 +67,7 @@ pub fn decode_torrent(bytes: &[u8]) -> Result> { /// /// This function will return an error if unable to bencode torrent. pub fn encode_torrent(torrent: &Torrent) -> Result, SerdeError> { - match serde_bencode::to_bytes(torrent) { - Ok(bencode_bytes) => Ok(bencode_bytes), - Err(e) => { - eprintln!("{e:?}"); - Err(e) - } - } + serde_bencode::to_bytes(torrent) } #[derive(Serialize, Deserialize, Debug, PartialEq)] From 685ad5ac75924748ee660eae830b32f7869a9080 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 12:50:02 +0200 Subject: [PATCH 06/13] feat(cli)!: migrate maintenance commands to ADR-T-010 Move import_tracker_statistics, seeder, and upgrade onto the shared ADR-T-010 CLI boundary for JSON clap help/version/usage records, JSON panic reporting, JSON stderr tracing, and explicit exit-code handling for no-stdout side-effect commands. Add an async no-stdout command runner in torrust-index-cli-common, wire the maintenance binaries through it, and convert command-reachable seeder, tracker statistics importer, and v1.0.0-to-v2.0.0 upgrade paths away from plain-text prints, terminal color formatting, unwraps, and panic-driven failures. Introduce typed upgrade errors for database setup, migrations, transfer validation, torrent decoding, missing torrent fields, and timestamp conversion, then propagate those failures through the upgrader and its tests. Update the changelog, README, ADR-T-010 rollout plan, and upgrade guide to document the migrated root maintenance command contract and remove the legacy text-colorizer dependency. BREAKING CHANGE: import_tracker_statistics, seeder, and upgrade now keep stdout empty and emit help, usage errors, status, diagnostics, tracing, and panic records as JSON/NDJSON on stderr instead of their previous plain-text output. --- CHANGELOG.md | 27 ++- Cargo.lock | 29 --- Cargo.toml | 1 - README.md | 25 ++- ...10-command-line-output-conformance-plan.md | 73 ++++--- packages/index-cli-common/src/lib.rs | 29 +++ src/bin/import_tracker_statistics.rs | 23 +- src/bin/seeder.rs | 21 +- src/bin/upgrade.rs | 36 +++- src/console/commands/seeder/api.rs | 119 ++++++++--- src/console/commands/seeder/app.rs | 112 ++++++---- .../tracker_statistics_importer/app.rs | 91 +++----- .../cronjobs/tracker_statistics_importer.rs | 77 +++++-- src/lib.rs | 14 +- src/tracker/statistics_importer.rs | 9 +- .../from_v1_0_0_to_v2_0_0/databases/mod.rs | 60 ++++-- .../databases/sqlite_v1_0_0.rs | 14 +- .../databases/sqlite_v2_0_0.rs | 54 +++-- src/upgrades/from_v1_0_0_to_v2_0_0/error.rs | 44 ++++ src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs | 1 + .../transferrers/category_transferrer.rs | 76 +++++-- .../transferrers/torrent_transferrer.rs | 199 ++++++++++-------- .../transferrers/tracker_key_transferrer.rs | 58 +++-- .../transferrers/user_transferrer.rs | 74 ++++--- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 96 +++------ .../torrent_transferrer_tester.rs | 2 +- .../from_v1_0_0_to_v2_0_0/upgrader.rs | 3 +- upgrades/from_v1_0_0_to_v2_0_0/README.md | 21 +- 28 files changed, 837 insertions(+), 551 deletions(-) create mode 100644 src/upgrades/from_v1_0_0_to_v2_0_0/error.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8a4e26..f0b7a6dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,10 +20,11 @@ error system (ADR-T-006), MSRV raised to 1.88. JSON-only output contract. Stdout is reserved for machine-readable result data, stderr is reserved for machine-readable diagnostics/control records, and stdout-producing commands refuse direct terminal stdout. `parse_torrent` now - emits JSON result data on stdout, and `create_test_torrent` now keeps stdout - empty while reporting status and diagnostics as JSON on stderr. Remaining - plain-text root maintenance command bodies are legacy gaps and will be - migrated in later stages. + emits JSON result data on stdout. `create_test_torrent`, + `import_tracker_statistics`, `seeder`, and `upgrade` now keep stdout empty + while reporting status and diagnostics as JSON on stderr. Scripts that scraped + their previous plain-text output must switch to exit codes and JSON/NDJSON + stderr parsing. - The `torrust-index` server's application logs now use JSON records on stderr instead of the previous human-formatted tracing output. Log consumers should parse stderr as NDJSON or pipe it through a JSON viewer. @@ -86,7 +87,8 @@ error system (ADR-T-006), MSRV raised to 1.88. `clap` help/version/usage wrapping, direct JSON stderr control-plane writes, a JSON-only panic hook, idempotent JSON stderr tracing with `RUST_LOG` / `--debug` precedence, a non-interleaving stderr writer, and stdout/no-stdout - command runners. + command runners, including an async runner for no-stdout side-effect + commands. #### Changed @@ -115,14 +117,17 @@ error system (ADR-T-006), MSRV raised to 1.88. - `create_test_torrent` now keeps stdout empty, reports the generated torrent path as a JSON status record on stderr, and converts argument, encode, file creation, and write failures into JSON diagnostics with explicit exit codes. -- Remaining root maintenance command internals may still contain legacy - plain-text output until later ADR-T-010 rollout stages migrate each command - body. +- `import_tracker_statistics`, `seeder`, and `upgrade` now use the shared JSON + `clap` parser, JSON panic hook, JSON stderr tracing runner, and no-stdout + side-effect command contract. Their command-reachable tracker statistics, + seeder, and upgrade paths now emit structured tracing diagnostics and + propagate command failures instead of printing plain text or relying on panic + output. - Operator documentation now describes the ADR-T-010 migration state: helper binaries have the JSON stdout contract, the server emits JSON tracing on - stderr, stage-five root commands have their JSON stream contract, and the - remaining root maintenance binaries plus the container entry script remain - legacy output gaps until their rollout stages land. + stderr, root Rust command migrations through stage six have their JSON stream + contracts, and the container entry script remains a legacy output gap until + its rollout stage lands. ### ADR-T-009 — Container infrastructure refactor diff --git a/Cargo.lock b/Cargo.lock index 7b431025..0c6594ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -591,16 +591,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "colored" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" -dependencies = [ - "lazy_static", - "windows-sys 0.59.0", -] - [[package]] name = "combine" version = "4.6.7" @@ -3901,15 +3891,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "text-colorizer" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30f9b94bd367aacc3f62cd28668b10c7ae1784c7d27e223a1c21646221a9166" -dependencies = [ - "colored", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4219,7 +4200,6 @@ dependencies = [ "sqlx", "tempfile", "tera", - "text-colorizer", "thiserror 2.0.18", "tokio", "toml 1.1.2+spec-1.1.0", @@ -4914,15 +4894,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 5ae876fa..343e0450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,7 +101,6 @@ sha-1 = "0" sha2 = "0" sqlx = { version = "0", features = ["migrate", "mysql", "runtime-tokio-native-tls", "sqlite", "time"] } tera = { version = "1", default-features = false } -text-colorizer = "1" thiserror = "2" tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } toml = "1" diff --git a/README.md b/README.md index ad04c712..9eb6d5c8 100644 --- a/README.md +++ b/README.md @@ -212,11 +212,26 @@ cargo run --quiet --bin create_test_torrent -- ./output/test/torrents 2>create-t jq . create-test-torrent.ndjson ``` -The remaining root maintenance binaries (`import_tracker_statistics`, `seeder`, -and `upgrade`) are still ADR-T-010 migration targets. Their `main` boundaries -use explicit exit codes and the shared JSON panic hook, but their command bodies -may still emit legacy plain text until their later rollout stages land. Do not -build new automation around their current plain-text output. +The root maintenance binaries `import_tracker_statistics`, `seeder`, and +`upgrade` are no-stdout side-effect commands. They keep stdout empty, use the +shared JSON `clap` wrapper for help, version, and argv errors, and emit status +or diagnostic records as JSON/NDJSON on stderr. Automation should branch on the +process exit code and parse stderr as JSON when it needs diagnostics: + +```sh +cargo run --quiet --bin import_tracker_statistics -- 2>import-tracker-statistics.ndjson + +cargo run --quiet --bin seeder -- \ + --api-base-url "http://localhost:3001" \ + --number-of-torrents 10 \ + --user admin \ + --password "$TORRUST_INDEX_ADMIN_PASSWORD" \ + --interval 0 \ + 2>seeder.ndjson + +cargo run --quiet --bin upgrade -- ./data.db ./data_v2.db ./uploads 2>upgrade.ndjson +jq . upgrade.ndjson +``` ## Documentation diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index 8f949488..a620a3d1 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -11,8 +11,8 @@ code, documentation, and regression tests. ## Current Implementation Status -Stages 1 through 5 have landed for the shared Rust helper path and initial root -command migrations: +Stages 1 through 6 have landed for the shared Rust helper path and root command +migrations: - Stage 1 fixed the shared control-plane record shape, baseline exit classes, helper stdout schemas, and redaction helpers. @@ -34,9 +34,14 @@ command migrations: contract tests. `parse_torrent` now emits one JSON stdout result object and refuses terminal stdout; `create_test_torrent` remains a no-stdout side-effect command. +- Stage 6 migrated `import_tracker_statistics`, `seeder`, and `upgrade` to the + shared JSON clap parser, JSON panic hook, JSON stderr tracing runner, empty + stdout side-effect contract, structured tracing diagnostics, and propagated + command errors. The command-reachable tracker statistics and upgrade modules + no longer emit raw stream output or terminal color formatting. -The remaining root maintenance command internals and container entry script -remain future rollout stages unless their sections below say otherwise. +The container entry script, remaining shared-library cleanup, and regression +guards remain future rollout stages unless their sections below say otherwise. ## Goal @@ -357,8 +362,11 @@ Required changes: Update every root maintenance and diagnostic binary. Stage 5 status: implemented for `src/bin/parse_torrent.rs` and -`src/bin/create_test_torrent.rs`. The remaining root maintenance binaries are -left for Stage 6. +`src/bin/create_test_torrent.rs`. + +Stage 6 status: implemented for `src/bin/import_tracker_statistics.rs`, +`src/bin/seeder.rs`, `src/bin/upgrade.rs`, and their command-reachable tracker +statistics, seeder, and upgrade modules. Required changes for `src/bin/parse_torrent.rs`: @@ -463,27 +471,27 @@ Required changes: Update operator documentation after the behavior changes. Current documentation status: the shared contract shape, helper stdout result -schemas, expanded Rust CLI infrastructure, helper-binary wiring state, and -stage-four server logging / root `ExitCode` boundary state have been documented. -The stage-five `parse_torrent` and `create_test_torrent` migration has also been -documented. Remaining root maintenance command internals and the container entry -script are still legacy output gaps until their rollout stages land; their -documentation should describe the ADR-T-010 target contract without promising -behaviour the binaries do not yet implement. - -Required changes: - -- Update `README.md` command examples that currently imply human-readable output. -- Update `docs/containers.md` for JSON stderr diagnostics, stdout result data, - helper TTY refusal, and recommended inspection patterns such as piping stdout - result data to `jq`. -- Update `upgrades/from_v1_0_0_to_v2_0_0/README.md` so upgrade examples describe - JSON stderr diagnostics and empty stdout. -- Update command module docs that currently show plain text output, especially - tracker statistics importer and upgrade docs. -- Add a `CHANGELOG.md` entry describing the operator-visible CLI output contract - change, marked as breaking for scripts that consumed the previous plain-text - command output. +schemas, expanded Rust CLI infrastructure, helper-binary wiring state, +stage-four server logging / root `ExitCode` boundary state, the stage-five +`parse_torrent` / `create_test_torrent` migration, and the stage-six root +maintenance command migration have been documented. The container entry script +is still a legacy output gap until its rollout stage lands; its documentation +should describe the ADR-T-010 target contract without promising behaviour it +does not yet implement. + +Documentation maintenance requirements: + +- Keep `README.md` command examples aligned with each command's current + stdout/stderr class. +- Keep `docs/containers.md` aligned with JSON stderr diagnostics, stdout result + data, helper TTY refusal, and recommended inspection patterns such as piping + stdout result data to `jq`. +- Keep `upgrades/from_v1_0_0_to_v2_0_0/README.md` aligned with `upgrade`'s JSON + stderr diagnostics and empty stdout contract. +- Keep command module docs aligned with the migrated command behavior, + especially tracker statistics importer and upgrade docs. +- Keep `CHANGELOG.md` entries marked as breaking when command output changes can + affect scripts that consumed previous plain-text output. ## Tests And Guards @@ -541,11 +549,12 @@ summarizing results, following the repository test-running convention. ## Rollout Order -Current status: steps 1 through 5 have landed. The documentation for the -shared-helper stages, the stage-four root logging / binary-boundary rollout, and -the initial stage-five root binary migration has been updated; the later -operator-visible migrations still need their own documentation and changelog -updates when they land. +Current status: steps 1 through 6 have landed. Documentation and changelog +entries for the shared-helper stages, the stage-four root logging / +binary-boundary rollout, the stage-five root binary migration, and the stage-six +root maintenance command migration have been updated. Later operator-visible +migrations still need their own documentation and changelog updates when they +land. 1. Finalize the shared control-plane record shape, command-specific result schema details, exit-code mapping, and redaction rules. diff --git a/packages/index-cli-common/src/lib.rs b/packages/index-cli-common/src/lib.rs index 844714e0..c8744e50 100644 --- a/packages/index-cli-common/src/lib.rs +++ b/packages/index-cli-common/src/lib.rs @@ -11,6 +11,7 @@ use std::borrow::Cow; use std::ffi::{OsStr, OsString}; +use std::future::Future; use std::io::{self, IsTerminal, Write}; use std::process::ExitCode; use std::sync::atomic::{AtomicBool, Ordering}; @@ -681,6 +682,34 @@ where } } +/// Run an async side-effect command that does not emit stdout result data. +/// +/// The runner installs the JSON panic hook, initialises JSON stderr tracing, and +/// maps failures to ADR-T-010 baseline exit classes. It deliberately does not +/// perform stdout TTY refusal because the command has no stdout result data. +pub async fn run_no_stdout_command_async( + command_name: &str, + debug: bool, + default_level: tracing::Level, + run: Run, +) -> ExitCode +where + CommandError: std::fmt::Display, + Run: FnOnce() -> RunFuture, + RunFuture: Future>, +{ + install_json_panic_hook(command_name); + init_json_tracing_with_debug(debug, default_level); + + match run().await { + Ok(()) => CommandExit::Success.exit_code(), + Err(error) => { + tracing::error!(error = %error, "command failed"); + CommandExit::Failure.exit_code() + } + } +} + struct LockedStderr; impl<'writer> MakeWriter<'writer> for LockedStderr { diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index 04f35fc6..d4cdac48 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -4,21 +4,32 @@ //! //! You can execute it with: `cargo run --bin import_tracker_statistics`. //! -//! ADR-T-010 classifies this as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until migration. +//! ADR-T-010 classifies this as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. use std::process::ExitCode; +use clap::Parser; use torrust_index::console::commands::tracker_statistics_importer::app::run; -use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; +use torrust_index_cli_common::{BaseArgs, install_json_panic_hook, parse_args_or_exit, run_no_stdout_command_async}; const COMMAND_NAME: &str = "import_tracker_statistics"; +#[derive(Parser)] +#[command( + name = "import_tracker_statistics", + version, + about = "Import torrent statistics from the linked tracker" +)] +struct Args { + #[command(flatten)] + base: BaseArgs, +} + #[tokio::main] async fn main() -> ExitCode { install_json_panic_hook(COMMAND_NAME); - run().await; + let args = parse_args_or_exit::(); - CommandExit::Success.exit_code() + run_no_stdout_command_async(COMMAND_NAME, args.base.debug, tracing::Level::INFO, run).await } diff --git a/src/bin/seeder.rs b/src/bin/seeder.rs index 9f75d774..26846b0a 100644 --- a/src/bin/seeder.rs +++ b/src/bin/seeder.rs @@ -1,13 +1,11 @@ //! Program to upload random torrents to a live Index API. //! -//! ADR-T-010 classifies this as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until migration. +//! ADR-T-010 classifies this as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. use std::process::ExitCode; -use torrust_index::console::commands::seeder::app; -use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; -use tracing::error; +use torrust_index::console::commands::seeder::app::{self, Args}; +use torrust_index_cli_common::{install_json_panic_hook, parse_args_or_exit, run_no_stdout_command_async}; const COMMAND_NAME: &str = "seeder"; @@ -15,11 +13,8 @@ const COMMAND_NAME: &str = "seeder"; async fn main() -> ExitCode { install_json_panic_hook(COMMAND_NAME); - match app::run().await { - Ok(()) => CommandExit::Success.exit_code(), - Err(error) => { - error!(%error, "command failed"); - CommandExit::Failure.exit_code() - } - } + let args = parse_args_or_exit::(); + let debug = args.base.debug; + + run_no_stdout_command_async(COMMAND_NAME, debug, tracing::Level::INFO, || app::run(args)).await } diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index 84e1c510..d579c52b 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -2,21 +2,43 @@ //! It updates the application from version v1.0.0 to v2.0.0. //! You can execute it with: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads`. //! -//! ADR-T-010 classifies this as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until migration. +//! ADR-T-010 classifies this as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. use std::process::ExitCode; -use torrust_index::upgrades::from_v1_0_0_to_v2_0_0::upgrader::run; -use torrust_index_cli_common::{CommandExit, install_json_panic_hook}; +use clap::Parser; +use torrust_index::upgrades::from_v1_0_0_to_v2_0_0::upgrader::{Arguments, run}; +use torrust_index_cli_common::{BaseArgs, install_json_panic_hook, parse_args_or_exit, run_no_stdout_command_async}; const COMMAND_NAME: &str = "upgrade"; +#[derive(Parser)] +#[command(name = "upgrade", version, about = "Upgrade Torrust Index data from v1.0.0 to v2.0.0")] +struct Args { + /// Source database file in version v1.0.0. + source_database_file: String, + + /// Target database file for version v2.0.0 data. + target_database_file: String, + + /// Directory where v1.0.0 torrent files are stored. + upload_path: String, + + #[command(flatten)] + base: BaseArgs, +} + #[tokio::main] async fn main() -> ExitCode { install_json_panic_hook(COMMAND_NAME); - run().await; + let args = parse_args_or_exit::(); + let debug = args.base.debug; + let command_args = Arguments { + source_database_file: args.source_database_file, + target_database_file: args.target_database_file, + upload_path: args.upload_path, + }; - CommandExit::Success.exit_code() + run_no_stdout_command_async(COMMAND_NAME, debug, tracing::Level::INFO, || run(command_args)).await } diff --git a/src/console/commands/seeder/api.rs b/src/console/commands/seeder/api.rs index 3bd0726b..d4666b90 100644 --- a/src/console/commands/seeder/api.rs +++ b/src/console/commands/seeder/api.rs @@ -2,7 +2,7 @@ use thiserror::Error; use tracing::debug; -use crate::web::api::client::v1::client::Client; +use crate::web::api::client::v1::client::{Client, Error as ClientError}; use crate::web::api::client::v1::contexts::category::forms::AddCategoryForm; use crate::web::api::client::v1::contexts::category::responses::{ListItem, ListResponse}; use crate::web::api::client::v1::contexts::torrent::forms::UploadTorrentMultipartForm; @@ -17,31 +17,55 @@ pub enum Error { TorrentInfoHashAlreadyExists, #[error("Torrent with the same title already exist in the database")] TorrentTitleAlreadyExists, + #[error("failed to fetch categories: {error:?}")] + FetchCategories { error: ClientError }, + #[error("category list API returned an unexpected response: status {status}, content type {content_type:?}")] + UnexpectedCategoryListResponse { status: u16, content_type: Option }, + #[error("failed to parse category list response: {source}")] + ParseCategoryListResponse { source: serde_json::Error }, + #[error("failed to add category: {error:?}")] + AddCategory { error: ClientError }, + #[error("add category API returned an unexpected response: status {status}, content type {content_type:?}")] + UnexpectedAddCategoryResponse { status: u16, content_type: Option }, + #[error("failed to build upload multipart form: {source}")] + BuildUploadForm { source: reqwest::Error }, + #[error("failed to upload torrent: {error:?}")] + UploadTorrent { error: ClientError }, + #[error("upload torrent API returned an unexpected response: status {status}, content type {content_type:?}")] + UnexpectedUploadTorrentResponse { status: u16, content_type: Option }, + #[error("failed to parse upload torrent response: {source}")] + ParseUploadTorrentResponse { source: serde_json::Error }, + #[error("failed to login: {error:?}")] + Login { error: ClientError }, + #[error("login API returned an unexpected response: status {status}, content type {content_type:?}")] + UnexpectedLoginResponse { status: u16, content_type: Option }, + #[error("failed to parse login response: {source}")] + ParseLoginResponse { source: serde_json::Error }, } /// It uploads a torrent file to the Torrust Index. /// /// # Errors /// -/// It returns an error if the torrent already exists in the database. -/// -/// # Panics -/// -/// Panics if the response body is not a valid JSON. +/// It returns an error if the torrent already exists in the database or if an +/// API response cannot be handled. pub async fn upload_torrent(client: &Client, upload_torrent_form: UploadTorrentMultipartForm) -> Result { - let categories = get_categories(client).await; + let categories = get_categories(client).await?; if !contains_category_with_name(&categories, &upload_torrent_form.category) { - add_category(client, &upload_torrent_form.category).await; + add_category(client, &upload_torrent_form.category).await?; } // todo: if we receive timeout error we should retry later. Otherwise we // have to restart the seeder manually. + let form = upload_torrent_form + .try_into() + .map_err(|source| Error::BuildUploadForm { source })?; let response = client - .upload_torrent(upload_torrent_form.try_into().expect("multipart form should be valid")) + .upload_torrent(form) .await - .expect("API should return a response"); + .map_err(|error| Error::UploadTorrent { error })?; debug!(target:"seeder", "response: {}", response.status); @@ -55,64 +79,91 @@ pub async fn upload_torrent(client: &Client, upload_torrent_form: UploadTorrentM } } - assert!(response.is_json_and_ok(), "Error uploading torrent: {}", response.body); + if !response.is_json_and_ok() { + return Err(Error::UnexpectedUploadTorrentResponse { + status: response.status, + content_type: response.content_type, + }); + } let uploaded_torrent_response: UploadedTorrentResponse = - serde_json::from_str(&response.body).expect("a valid JSON response should be returned from the Torrust Index API"); + serde_json::from_str(&response.body).map_err(|source| Error::ParseUploadTorrentResponse { source })?; Ok(uploaded_torrent_response.data) } /// It logs in the user and returns the user data. /// -/// # Panics +/// # Errors /// -/// Panics if the response body is not a valid JSON. -pub async fn login(client: &Client, username: &str, password: &str) -> LoggedInUserData { +/// Returns an error if the API request fails or the response cannot be handled. +pub async fn login(client: &Client, username: &str, password: &str) -> Result { let response = client .login_user(LoginForm { login: username.to_owned(), password: password.to_owned(), }) .await - .expect("API should return a response"); + .map_err(|error| Error::Login { error })?; - let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap_or_else(|_| { - panic!( - "a valid JSON response should be returned after login. Received: {}", - response.body - ) - }); + if !response.is_json_and_ok() { + return Err(Error::UnexpectedLoginResponse { + status: response.status, + content_type: response.content_type, + }); + } + + let res: SuccessfulLoginResponse = + serde_json::from_str(&response.body).map_err(|source| Error::ParseLoginResponse { source })?; - res.data + Ok(res.data) } /// It returns all the index categories. /// -/// # Panics +/// # Errors /// -/// Panics if the response body is not a valid JSON. -pub async fn get_categories(client: &Client) -> Vec { - let response = client.get_categories().await.expect("API should return a response"); +/// Returns an error if the API request fails or the response cannot be handled. +pub async fn get_categories(client: &Client) -> Result, Error> { + let response = client + .get_categories() + .await + .map_err(|error| Error::FetchCategories { error })?; + + if !response.is_json_and_ok() { + return Err(Error::UnexpectedCategoryListResponse { + status: response.status, + content_type: response.content_type, + }); + } - let res: ListResponse = serde_json::from_str(&response.body).unwrap(); + let res: ListResponse = serde_json::from_str(&response.body).map_err(|source| Error::ParseCategoryListResponse { source })?; - res.data + Ok(res.data) } /// It adds a new category. /// -/// # Panics +/// # Errors /// -/// Will panic if it doesn't get a response form the API. -pub async fn add_category(client: &Client, name: &str) -> TextResponse { - client +/// Returns an error if the API request fails or the response cannot be handled. +pub async fn add_category(client: &Client, name: &str) -> Result { + let response = client .add_category(AddCategoryForm { name: name.to_owned(), icon: None, }) .await - .expect("API should return a response") + .map_err(|error| Error::AddCategory { error })?; + + if !response.is_json_and_ok() { + return Err(Error::UnexpectedAddCategoryResponse { + status: response.status, + content_type: response.content_type, + }); + } + + Ok(response) } /// It checks if the category list contains the given category. diff --git a/src/console/commands/seeder/app.rs b/src/console/commands/seeder/app.rs index f1e1a417..2289ac9a 100644 --- a/src/console/commands/seeder/app.rs +++ b/src/console/commands/seeder/app.rs @@ -1,9 +1,7 @@ //! Console app to upload random torrents to a live Index API. //! -//! ADR-T-010 classifies this as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until the command is migrated, so do not parse its current -//! human-formatted logs in automation. +//! ADR-T-010 classifies this as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. //! //! Run with: //! @@ -28,7 +26,7 @@ //! ``` //! //! That command would upload 1000 random torrents to the Index using the user -//! account admin with password 123456 and waiting 1 second between uploads. +//! account `admin` with password `12345678` and no delay between uploads. //! //! The random torrents generated are single-file torrents from a TXT file. //! All generated torrents used a UUID to identify the test torrent. The torrent @@ -132,20 +130,17 @@ //! //! As you can see the `info` dictionary is exactly the same, which produces //! the same info-hash for the torrent. -use std::str::FromStr; -use std::thread::sleep; use std::time::Duration; use clap::Parser; use reqwest::Url; -use text_colorizer::Colorize; -use tracing::level_filters::LevelFilter; -use tracing::{debug, info}; +use thiserror::Error; +use torrust_index_cli_common::BaseArgs; +use tracing::{debug, error, info}; use uuid::Uuid; -use super::api::Error; +use super::api::Error as ApiError; use crate::console::commands::seeder::api::{login, upload_torrent}; -use crate::console::commands::seeder::logging; use crate::services::torrent_file::generate_random_torrent; use crate::utils::parse_torrent; use crate::web::api::client::v1::client::Client; @@ -153,9 +148,9 @@ use crate::web::api::client::v1::contexts::torrent::forms::{BinaryFile, UploadTo use crate::web::api::client::v1::contexts::torrent::responses::UploadedTorrent; use crate::web::api::client::v1::contexts::user::responses::LoggedInUserData; -#[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] -struct Args { +#[derive(Parser)] +#[command(name = "seeder", version, about = "Upload random torrents to a live Index API", long_about = None)] +pub struct Args { #[arg(short, long)] api_base_url: String, @@ -170,42 +165,62 @@ struct Args { #[arg(short, long)] interval: u64, + + #[command(flatten)] + pub base: BaseArgs, } -/// # Errors -/// -/// Returns an error if the API base URL cannot be parsed or if an uploaded -/// torrent cannot be serialized to JSON. -pub async fn run() -> Result<(), Box> { - logging::setup(LevelFilter::INFO); +#[derive(Debug, Error)] +pub enum CommandError { + #[error("failed to parse API base URL: {source}")] + ParseApiBaseUrl { source: url::ParseError }, + + #[error("failed to login to the Index API: {source}")] + Login { source: ApiError }, + + #[error("failed to upload torrent to the Index API: {source}")] + UploadTorrent { source: ApiError }, + + #[error("failed to encode generated torrent: {source}")] + EncodeTorrent { source: serde_bencode::Error }, - let args = Args::parse(); + #[error("failed to serialize upload response into JSON: {source}")] + SerializeUploadResponse { source: serde_json::Error }, +} - let api_url = Url::from_str(&args.api_base_url).map_err(|e| format!("failed to parse API base URL: {e}"))?; +/// # Errors +/// +/// Returns an error if setup fails. Individual torrent upload failures are +/// logged and the command continues with the next generated torrent. +pub async fn run(args: Args) -> Result<(), CommandError> { + let api_url = args + .api_base_url + .parse::() + .map_err(|source| CommandError::ParseApiBaseUrl { source })?; - let api_user = login_index_api(&api_url, &args.user, &args.password).await; + let api_user = login_index_api(&api_url, &args.user, &args.password).await?; let api_client = Client::authenticated(&api_url, &api_user.token); - info!(target:"seeder", "Uploading { } random torrents to the Torrust Index with a { } seconds interval...", args.number_of_torrents.to_string().yellow(), args.interval.to_string().yellow()); + info!(target:"seeder", number_of_torrents = args.number_of_torrents, interval_seconds = args.interval, "uploading random torrents to the Index API"); for i in 1..=args.number_of_torrents { - info!(target:"seeder", "Uploading torrent #{} ...", i.to_string().yellow()); + info!(target:"seeder", torrent_number = i, "uploading torrent"); match upload_random_torrent(&api_client).await { Ok(uploaded_torrent) => { - debug!(target:"seeder", "Uploaded torrent {uploaded_torrent:?}"); + debug!(target:"seeder", ?uploaded_torrent, "uploaded torrent"); let json = serde_json::to_string(&uploaded_torrent) - .map_err(|e| format!("failed to serialize upload response into JSON: {e}"))?; + .map_err(|source| CommandError::SerializeUploadResponse { source })?; - info!(target:"seeder", "Uploaded torrent: {}", json.yellow()); + info!(target:"seeder", uploaded_torrent = %json, "uploaded torrent"); } - Err(err) => print!("Error uploading torrent {err:?}"), + Err(error) => error!(target:"seeder", torrent_number = i, %error, "failed to upload torrent"), } if i != args.number_of_torrents { - sleep(Duration::from_secs(args.interval)); + tokio::time::sleep(Duration::from_secs(args.interval)).await; } } @@ -213,28 +228,35 @@ pub async fn run() -> Result<(), Box> { } /// It logs in a user in the Index API. -pub async fn login_index_api(api_url: &Url, username: &str, password: &str) -> LoggedInUserData { +/// +/// # Errors +/// +/// Returns an error if the login request fails or the response cannot be +/// decoded. +pub async fn login_index_api(api_url: &Url, username: &str, password: &str) -> Result { let unauthenticated_client = Client::unauthenticated(api_url); - info!(target:"seeder", "Trying to login with username: {} ...", username.yellow()); + info!(target:"seeder", username, "trying to login"); - let user: LoggedInUserData = login(&unauthenticated_client, username, password).await; + let user: LoggedInUserData = login(&unauthenticated_client, username, password) + .await + .map_err(|source| CommandError::Login { source })?; if user.role == "admin" { - info!(target:"seeder", "Logged as admin with account: {} ", username.yellow()); + info!(target:"seeder", username, role = %user.role, "logged in as admin"); } else { - info!(target:"seeder", "Logged as {} ", username.yellow()); + info!(target:"seeder", username, role = %user.role, "logged in"); } - user + Ok(user) } -async fn upload_random_torrent(api_client: &Client) -> Result { +async fn upload_random_torrent(api_client: &Client) -> Result { let uuid = Uuid::new_v4(); - info!(target:"seeder", "Uploading torrent with uuid: {} ...", uuid.to_string().yellow()); + info!(target:"seeder", %uuid, "uploading torrent with uuid"); - let torrent_file = generate_random_torrent_file(uuid); + let torrent_file = generate_random_torrent_file(uuid)?; let upload_form = UploadTorrentMultipartForm { title: format!("title-{uuid}"), @@ -243,14 +265,16 @@ async fn upload_random_torrent(api_client: &Client) -> Result BinaryFile { +fn generate_random_torrent_file(uuid: Uuid) -> Result { let torrent = generate_random_torrent(uuid); - let bytes = parse_torrent::encode_torrent(&torrent).expect("msg:the torrent should be bencoded"); + let bytes = parse_torrent::encode_torrent(&torrent).map_err(|source| CommandError::EncodeTorrent { source })?; - BinaryFile::from_bytes(torrent.info.name, bytes) + Ok(BinaryFile::from_bytes(torrent.info.name, bytes)) } diff --git a/src/console/commands/tracker_statistics_importer/app.rs b/src/console/commands/tracker_statistics_importer/app.rs index 71847272..88570eb8 100644 --- a/src/console/commands/tracker_statistics_importer/app.rs +++ b/src/console/commands/tracker_statistics_importer/app.rs @@ -5,10 +5,8 @@ //! //! You can execute it with: `cargo run --bin import_tracker_statistics`. //! -//! ADR-T-010 classifies this as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until the command is migrated, so do not parse its current -//! plain-text diagnostics in automation. +//! ADR-T-010 classifies this as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. //! //! Statistics are also imported: //! @@ -18,91 +16,66 @@ //! - When a new torrent is added. //! - When the API returns data about a torrent statistics are collected from //! the tracker in real time. -use std::env; use std::sync::Arc; -use text_colorizer::Colorize; use thiserror::Error; +use tracing::info; -use crate::bootstrap::config::initialize_configuration; -use crate::bootstrap::logging; +use crate::bootstrap::config::DEFAULT_PATH_CONFIG; +use crate::config::{Configuration, Error as ConfigError, Info}; use crate::databases::database; use crate::tracker::service::Service; use crate::tracker::statistics_importer::StatisticsImporter; -const NUMBER_OF_ARGUMENTS: usize = 0; - -#[derive(Debug, PartialEq, Eq, Error)] -#[allow(dead_code)] +#[derive(Debug, Error)] pub enum ImportError { - #[error("internal server error")] - WrongNumberOfArgumentsError, -} - -fn parse_args() -> Result<(), ImportError> { - let args: Vec = env::args().skip(1).collect(); - - if args.len() != NUMBER_OF_ARGUMENTS { - eprintln!( - "{} wrong number of arguments: expected {}, got {}", - "Error".red().bold(), - NUMBER_OF_ARGUMENTS, - args.len() - ); - print_usage(); - return Err(ImportError::WrongNumberOfArgumentsError); - } - - Ok(()) -} + #[error("failed to build configuration lookup: {source}")] + BuildConfigurationInfo { source: ConfigError }, -fn print_usage() { - eprintln!( - "{} - imports torrents statistics from linked tracker. + #[error("failed to load configuration: {source}")] + LoadConfiguration { source: ConfigError }, - cargo run --bin import_tracker_statistics + #[error("failed to connect to database: {source}")] + ConnectDatabase { source: database::Error }, - ", - "Tracker Statistics Importer".green() - ); + #[error("failed to import tracker statistics: {source}")] + ImportStatistics { source: database::Error }, } /// Import Tracker Statistics Command /// -/// # Panics +/// # Errors /// -/// Panics if arguments cannot be parsed. -pub async fn run() { - parse_args().expect("unable to parse command arguments"); - import().await; +/// Returns an error if configuration loading, database connection, or the +/// statistics import fails. +pub async fn run() -> Result<(), ImportError> { + import().await } /// Import Command Arguments /// -/// # Panics +/// # Errors /// -/// Panics if it can't connect to the database. -pub async fn import() { - println!("Importing statistics from linked tracker ..."); - - let configuration = initialize_configuration(); +/// Returns an error if configuration loading, database connection, or the +/// statistics import fails. +pub async fn import() -> Result<(), ImportError> { + info!("importing statistics from linked tracker"); - let threshold = configuration.settings.read().await.logging.threshold.clone(); - - logging::setup(&threshold); + let config_info = + Info::new(DEFAULT_PATH_CONFIG.to_string()).map_err(|source| ImportError::BuildConfigurationInfo { source })?; + let configuration = Configuration::load(&config_info).map_err(|source| ImportError::LoadConfiguration { source })?; let cfg = Arc::new(configuration); let settings = cfg.settings.read().await; let tracker_url = settings.tracker.url.clone(); - - eprintln!("Tracker url: {}", tracker_url.to_string().green()); + info!(tracker_url = %tracker_url, "loaded tracker configuration"); let database = Arc::new( database::connect(settings.database.connect_url.as_ref()) .await - .expect("unable to connect to db"), + .map_err(|source| ImportError::ConnectDatabase { source })?, ); drop(settings); @@ -113,5 +86,9 @@ pub async fn import() { tracker_statistics_importer .import_all_torrents_statistics() .await - .expect("should import all torrents statistics"); + .map_err(|source| ImportError::ImportStatistics { source })?; + + info!("imported statistics from linked tracker"); + + Ok(()) } diff --git a/src/console/cronjobs/tracker_statistics_importer.rs b/src/console/cronjobs/tracker_statistics_importer.rs index 3c726a37..15b1906f 100644 --- a/src/console/cronjobs/tracker_statistics_importer.rs +++ b/src/console/cronjobs/tracker_statistics_importer.rs @@ -18,7 +18,6 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use chrono::{DateTime, Utc}; use serde_json::{Value, json}; -use text_colorizer::Colorize; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tracing::{debug, error, info}; @@ -37,9 +36,10 @@ struct ImporterState { pub torrent_info_update_interval: u64, } -/// # Panics +/// Start the tracker statistics importer launcher task. /// -/// Will panic if it can't start the tracker statistics importer API +/// Startup failures inside the spawned importer API task are logged as tracing +/// diagnostics and stop that task. #[must_use] pub fn start( importer_port: u16, @@ -70,13 +70,25 @@ pub fn start( info!("Tracker statistics importer API server listening on http://{}", addr); // # DevSkim: ignore DS137138 - let socket_addr: SocketAddr = addr.parse().expect("importer API to have a valid socket address"); + let socket_addr: SocketAddr = match addr.parse() { + Ok(socket_addr) => socket_addr, + Err(error) => { + error!(%error, %addr, "invalid importer API socket address"); + return; + } + }; - let listener = TcpListener::bind(socket_addr) - .await - .expect("importer API TCP listener to bind to socket address"); + let listener = match TcpListener::bind(socket_addr).await { + Ok(listener) => listener, + Err(error) => { + error!(%error, %socket_addr, "failed to bind importer API TCP listener"); + return; + } + }; - axum::serve(listener, app).await.unwrap(); + if let Err(error) = axum::serve(listener, app).await { + error!(%error, "importer API server stopped with an error"); + } }); // Start the Importer cronjob @@ -119,17 +131,20 @@ pub fn start( match weak_tracker_statistics_importer.upgrade() { Some(statistics_importer) => { - let one_interval_ago = seconds_ago_utc( - torrent_stats_update_interval - .try_into() - .expect("update interval should be a positive integer"), - ); + let Ok(update_interval_seconds) = torrent_stats_update_interval.try_into() else { + error!( + torrent_stats_update_interval, + "update interval does not fit in signed seconds" + ); + break; + }; + let one_interval_ago = seconds_ago_utc(update_interval_seconds); let limit = 50; debug!( - "Importing torrents statistics not updated since {} limited to a maximum of {} torrents ...", - one_interval_ago.to_string().yellow(), - limit.to_string().yellow() + since = %one_interval_ago, + limit, + "importing torrents statistics not updated since threshold" ); match statistics_importer @@ -154,13 +169,26 @@ pub fn start( /// Endpoint for container health check. async fn health_check_handler(State(state): State>) -> Json { - let margin_in_seconds = 10; + let margin_in_seconds = 10_u64; let now = Utc::now(); - let last_heartbeat = state.last_heartbeat.lock().unwrap(); - - if now.signed_duration_since(*last_heartbeat).num_seconds() - <= (state.torrent_info_update_interval + margin_in_seconds).try_into().unwrap() - { + let Ok(last_heartbeat) = state.last_heartbeat.lock() else { + error!("failed to acquire importer heartbeat lock"); + return Json(json!({ "status": "Error" })); + }; + + let Ok(max_heartbeat_age_seconds) = state + .torrent_info_update_interval + .saturating_add(margin_in_seconds) + .try_into() + else { + error!( + torrent_info_update_interval = state.torrent_info_update_interval, + "importer health check interval does not fit in signed seconds" + ); + return Json(json!({ "status": "Error" })); + }; + + if now.signed_duration_since(*last_heartbeat).num_seconds() <= max_heartbeat_age_seconds { Json(json!({ "status": "Ok" })) } else { Json(json!({ "status": "Error" })) @@ -171,7 +199,10 @@ async fn health_check_handler(State(state): State>) -> Json>) -> Json { let now = Utc::now(); - let mut last_heartbeat = state.last_heartbeat.lock().unwrap(); + let Ok(mut last_heartbeat) = state.last_heartbeat.lock() else { + error!("failed to acquire importer heartbeat lock"); + return Json(json!({ "status": "Error" })); + }; *last_heartbeat = now; drop(last_heartbeat); Json(json!({ "status": "Heartbeat received" })) diff --git a/src/lib.rs b/src/lib.rs index 87a7fd07..ff01faf6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -232,10 +232,9 @@ //! ## Tracker Statistics Importer //! //! This console command allows you to manually import the tracker statistics. -//! ADR-T-010 classifies it as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until the command is migrated, so scripts should not parse -//! its plain-text diagnostics. +//! ADR-T-010 classifies it as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. Scripts should branch on the process +//! exit code and parse stderr as NDJSON when they need diagnostics. //! //! For more information about this command you can visit the documentation for //! the [`Import tracker statistics`](crate::console::commands::tracker_statistics_importer) module. @@ -244,10 +243,9 @@ //! //! This console command allows you to manually upgrade the application from one //! version to another. -//! ADR-T-010 classifies it as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until the command is migrated, so scripts should branch on -//! exit status rather than parsing current plain text. +//! ADR-T-010 classifies it as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. Scripts should branch on the process +//! exit code and parse stderr as NDJSON when they need diagnostics. //! //! For more information about this command you can visit the documentation for //! the [`Upgrade app from version 1.0.0 to 2.0.0`](crate::upgrades::from_v1_0_0_to_v2_0_0::upgrader) module. diff --git a/src/tracker/statistics_importer.rs b/src/tracker/statistics_importer.rs index 30b36856..666c1037 100644 --- a/src/tracker/statistics_importer.rs +++ b/src/tracker/statistics_importer.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use std::time::Instant; use chrono::{DateTime, Utc}; -use text_colorizer::Colorize; use tracing::{debug, error, info}; use url::Url; @@ -42,13 +41,13 @@ impl StatisticsImporter { return Ok(()); } - info!(target: LOG_TARGET, "Importing {} torrents statistics from tracker {} ...", torrents.len().to_string().yellow(), self.tracker_url.to_string().yellow()); + info!(target: LOG_TARGET, torrent_count = torrents.len(), tracker_url = %self.tracker_url, "importing torrents statistics from tracker"); // Start the timer before the loop let start_time = Instant::now(); for torrent in torrents { - info!(target: LOG_TARGET, "Importing torrent #{} statistics ...", torrent.torrent_id.to_string().yellow()); + info!(target: LOG_TARGET, torrent_id = torrent.torrent_id, "importing torrent statistics"); let ret = self.import_torrent_statistics(torrent.torrent_id, &torrent.info_hash).await; @@ -81,7 +80,7 @@ impl StatisticsImporter { datetime: DateTime, limit: i64, ) -> Result<(), database::Error> { - debug!(target: LOG_TARGET, "Importing torrents statistics not updated since {} limited to a maximum of {} torrents ...", datetime.to_string().yellow(), limit.to_string().yellow()); + debug!(target: LOG_TARGET, since = %datetime, limit, "importing torrents statistics not updated since threshold"); let torrents = self .database @@ -92,7 +91,7 @@ impl StatisticsImporter { return Ok(()); } - info!(target: LOG_TARGET, "Importing {} torrents statistics from tracker {} ...", torrents.len().to_string().yellow(), self.tracker_url.to_string().yellow()); + info!(target: LOG_TARGET, torrent_count = torrents.len(), tracker_url = %self.tracker_url, "importing torrents statistics from tracker"); // Import stats for all torrents in one request diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs index 44af94f9..0ea601bb 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/mod.rs @@ -1,35 +1,71 @@ use std::sync::Arc; +use tracing::info; + use self::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use self::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::error::UpgradeError; pub mod sqlite_v1_0_0; pub mod sqlite_v2_0_0; -pub async fn current_db(db_filename: &str) -> Arc { +/// Open the source v1.0.0 `SQLite` database in read-only mode. +/// +/// # Errors +/// +/// Returns an error if the database pool cannot be created. +pub async fn current_db(db_filename: &str) -> Result, UpgradeError> { let source_database_connect_url = format!("sqlite://{db_filename}?mode=ro"); - Arc::new(SqliteDatabaseV1_0_0::new(&source_database_connect_url).await) + let database = SqliteDatabaseV1_0_0::new(&source_database_connect_url) + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to open source database", + source, + })?; + Ok(Arc::new(database)) } -pub async fn new_db(db_filename: &str) -> Arc { +/// Open the target v2.0.0 `SQLite` database. +/// +/// # Errors +/// +/// Returns an error if the database pool cannot be created. +pub async fn new_db(db_filename: &str) -> Result, UpgradeError> { let target_database_connect_url = format!("sqlite://{db_filename}?mode=rwc"); - Arc::new(SqliteDatabaseV2_0_0::new(&target_database_connect_url).await) + let database = SqliteDatabaseV2_0_0::new(&target_database_connect_url) + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to open target database", + source, + })?; + Ok(Arc::new(database)) } -pub async fn migrate_target_database(target_database: Arc) { - println!("Running migrations in the target database..."); - target_database.migrate().await; +/// Run target database migrations. +/// +/// # Errors +/// +/// Returns an error if any migration fails. +pub async fn migrate_target_database(target_database: Arc) -> Result<(), UpgradeError> { + info!("running migrations in the target database"); + target_database.migrate().await.map_err(|source| UpgradeError::Migration { + context: "failed to run target database migrations", + source, + }) } /// It truncates all tables in the target database. /// -/// # Panics +/// # Errors /// -/// It panics if it cannot truncate the tables. -pub async fn truncate_target_database(target_database: Arc) { - println!("Truncating all tables in target database ..."); +/// Returns an error if any target database table cannot be truncated. +pub async fn truncate_target_database(target_database: Arc) -> Result<(), UpgradeError> { + info!("truncating all tables in target database"); target_database .delete_all_database_rows() .await - .expect("Can't reset the target database."); + .map_err(|source| UpgradeError::Database { + context: "failed to truncate target database", + source, + }) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs index 89be4c49..5ee8ab5d 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v1_0_0.rs @@ -60,15 +60,13 @@ pub struct SqliteDatabaseV1_0_0 { impl SqliteDatabaseV1_0_0 { /// It creates a new instance of the `SqliteDatabaseV1_0_0`. /// - /// # Panics + /// # Errors /// - /// This function will panic if it is unable to create the database pool. - pub async fn new(database_url: &str) -> Self { - let db = SqlitePoolOptions::new() - .connect(database_url) - .await - .expect("Unable to create database pool."); - Self { pool: db } + /// This function will return an error if it is unable to create the + /// database pool. + pub async fn new(database_url: &str) -> Result { + let db = SqlitePoolOptions::new().connect(database_url).await?; + Ok(Self { pool: db }) } pub async fn get_categories_order_by_id(&self) -> Result, database::Error> { diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs index 8cdb14db..585abdd2 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/databases/sqlite_v2_0_0.rs @@ -8,6 +8,7 @@ use sqlx::{SqlitePool, query, query_as}; use super::sqlite_v1_0_0::{TorrentRecordV1, UserRecordV1}; use crate::databases::database::{self, TABLES_TO_TRUNCATE}; use crate::models::torrent_file::{TorrentFile, TorrentInfoDictionary}; +use crate::upgrades::from_v1_0_0_to_v2_0_0::error::UpgradeError; #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct CategoryRecordV2 { @@ -32,9 +33,12 @@ pub struct TorrentRecordV2 { } impl TorrentRecordV2 { - #[must_use] - pub fn from_v1_data(torrent: &TorrentRecordV1, torrent_info: &TorrentInfoDictionary, uploader: &UserRecordV1) -> Self { - Self { + pub fn from_v1_data( + torrent: &TorrentRecordV1, + torrent_info: &TorrentInfoDictionary, + uploader: &UserRecordV1, + ) -> Result { + Ok(Self { torrent_id: torrent.torrent_id, uploader_id: uploader.user_id, category_id: torrent.category_id, @@ -46,25 +50,26 @@ impl TorrentRecordV2 { piece_length: torrent_info.piece_length, private: torrent_info.private, is_bep_30: i64::from(torrent_info.is_bep_30()), - date_uploaded: convert_timestamp_to_datetime(torrent.upload_date), - } + date_uploaded: convert_timestamp_to_datetime(torrent.upload_date)?, + }) } } /// It converts a timestamp in seconds to a datetime string. /// -/// # Panics +/// # Errors /// -/// It panics if the timestamp is too big and it overflows i64. Very future! -#[must_use] -pub fn convert_timestamp_to_datetime(timestamp: i64) -> String { +/// Returns an error if the timestamp is outside the supported range. +pub fn convert_timestamp_to_datetime(timestamp: i64) -> Result { // The expected format in database is: 2022-11-04 09:53:57 // MySQL uses a DATETIME column and SQLite uses a TEXT column. - let datetime = DateTime::from_timestamp(timestamp, 0).expect("Overflow of i64 seconds, very future!"); + let Some(datetime) = DateTime::from_timestamp(timestamp, 0) else { + return Err(UpgradeError::InvalidTimestamp { timestamp }); + }; // Format without timezone - datetime.format("%Y-%m-%d %H:%M:%S").to_string() + Ok(datetime.format("%Y-%m-%d %H:%M:%S").to_string()) } pub struct SqliteDatabaseV2_0_0 { @@ -74,27 +79,21 @@ pub struct SqliteDatabaseV2_0_0 { impl SqliteDatabaseV2_0_0 { /// Creates a new instance of the database. /// - /// # Panics + /// # Errors /// - /// It panics if it cannot create the database pool. - pub async fn new(database_url: &str) -> Self { - let db = SqlitePoolOptions::new() - .connect(database_url) - .await - .expect("Unable to create database pool."); - Self { pool: db } + /// Returns an error if it cannot create the database pool. + pub async fn new(database_url: &str) -> Result { + let db = SqlitePoolOptions::new().connect(database_url).await?; + Ok(Self { pool: db }) } /// It migrates the database to the latest version. /// - /// # Panics + /// # Errors /// - /// It panics if it cannot run the migrations. - pub async fn migrate(&self) { - sqlx::migrate!("migrations/sqlite3") - .run(&self.pool) - .await - .expect("Could not run database migrations."); + /// Returns an error if it cannot run the migrations. + pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> { + sqlx::migrate!("migrations/sqlite3").run(&self.pool).await } pub async fn reset_categories_sequence(&self) -> Result { @@ -267,13 +266,12 @@ impl SqliteDatabaseV2_0_0 { .map(|v| v.last_insert_rowid()) } - #[allow(clippy::missing_panics_doc)] pub async fn delete_all_database_rows(&self) -> Result<(), database::Error> { for table in TABLES_TO_TRUNCATE { query(&format!("DELETE FROM {table};")) .execute(&self.pool) .await - .unwrap_or_else(|_| panic!("table {table} should be deleted")); + .map_err(|_| database::Error::Error)?; } Ok(()) diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/error.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/error.rs new file mode 100644 index 00000000..9a22e060 --- /dev/null +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/error.rs @@ -0,0 +1,44 @@ +use thiserror::Error; + +use crate::databases::database; + +#[derive(Debug, Error)] +pub enum UpgradeError { + #[error("{context}: {source}")] + Database { context: &'static str, source: database::Error }, + + #[error("{context}: {source}")] + Sqlx { context: &'static str, source: sqlx::Error }, + + #[error("{context}: {source}")] + Migration { + context: &'static str, + source: sqlx::migrate::MigrateError, + }, + + #[error("{context}: {source}")] + Io { context: &'static str, source: std::io::Error }, + + #[error("failed to decode torrent file {path}: {message}")] + DecodeTorrent { path: String, message: String }, + + #[error("{entity} id mismatch while copying data: expected {expected}, got {actual}")] + IdMismatch { + entity: &'static str, + expected: i64, + actual: i64, + }, + + #[error("uploader mismatch for torrent {torrent_id}: expected {expected}, got {actual}")] + UploaderMismatch { + torrent_id: i64, + expected: String, + actual: String, + }, + + #[error("missing {field} for torrent {torrent_id}")] + MissingTorrentField { torrent_id: i64, field: &'static str }, + + #[error("invalid timestamp {timestamp}")] + InvalidTimestamp { timestamp: i64 }, +} diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs index afb35f90..894793fe 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/mod.rs @@ -1,3 +1,4 @@ pub mod databases; +pub mod error; pub mod transferrers; pub mod upgrader; diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs index 02472ec1..9605daa4 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/category_transferrer.rs @@ -1,37 +1,73 @@ use std::sync::Arc; +use tracing::{debug, info}; + use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{CategoryRecordV2, SqliteDatabaseV2_0_0}; +use crate::upgrades::from_v1_0_0_to_v2_0_0::error::UpgradeError; -#[allow(clippy::missing_panics_doc)] -pub async fn transfer_categories(source_database: Arc, target_database: Arc) { - println!("Transferring categories ..."); +/// Transfer categories from the source database to the target database. +/// +/// # Errors +/// +/// Returns an error if reading, inserting, or validating copied category data +/// fails. +pub async fn transfer_categories( + source_database: Arc, + target_database: Arc, +) -> Result<(), UpgradeError> { + info!("transferring categories"); - let source_categories = source_database.get_categories_order_by_id().await.unwrap(); - println!("[v1] categories: {source_categories:?}"); + let source_categories = source_database + .get_categories_order_by_id() + .await + .map_err(|source| UpgradeError::Database { + context: "failed to read source categories", + source, + })?; + debug!(?source_categories, "read source categories"); - let result = target_database.reset_categories_sequence().await.unwrap(); - println!("[v2] reset categories sequence result: {result:?}"); + let result = target_database + .reset_categories_sequence() + .await + .map_err(|source| UpgradeError::Database { + context: "failed to reset target category sequence", + source, + })?; + debug!(?result, "reset target category sequence"); - for cat in &source_categories { - println!("[v2] adding category {:?} with id {:?} ...", cat.name, cat.category_id); + for category in &source_categories { + info!(category_id = category.category_id, name = %category.name, "adding category"); let id = target_database .insert_category(&CategoryRecordV2 { - category_id: cat.category_id, - name: cat.name.clone(), + category_id: category.category_id, + name: category.name.clone(), }) .await - .unwrap(); + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert target category", + source, + })?; - assert!( - id == cat.category_id, - "Error copying category {:?} from source DB to the target DB", - cat.category_id - ); + if id != category.category_id { + return Err(UpgradeError::IdMismatch { + entity: "category", + expected: category.category_id, + actual: id, + }); + } - println!("[v2] category: {:?} {:?} added.", id, cat.name); + info!(category_id = id, name = %category.name, "category added"); } - let target_categories = target_database.get_categories().await.unwrap(); - println!("[v2] categories: {target_categories:?}"); + let target_categories = target_database + .get_categories() + .await + .map_err(|source| UpgradeError::Database { + context: "failed to read target categories", + source, + })?; + debug!(?target_categories, "read target categories"); + + Ok(()) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs index 9a58755c..fa230744 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/torrent_transferrer.rs @@ -1,21 +1,23 @@ #![allow(clippy::missing_errors_doc)] +use std::fs; use std::sync::Arc; -use std::{error, fs}; + +use tracing::{debug, info}; use crate::models::torrent_file::Torrent; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::{SqliteDatabaseV2_0_0, TorrentRecordV2}; +use crate::upgrades::from_v1_0_0_to_v2_0_0::error::UpgradeError; use crate::utils::parse_torrent::decode_torrent; -#[allow(clippy::missing_panics_doc)] #[allow(clippy::too_many_lines)] pub async fn transfer_torrents( source_database: Arc, target_database: Arc, upload_path: &str, -) { - println!("Transferring torrents ..."); +) -> Result<(), UpgradeError> { + info!("transferring torrents"); // Transfer table `torrust_torrents_files` @@ -24,149 +26,172 @@ pub async fn transfer_torrents( // Transfer table `torrust_torrents` - let torrents = source_database.get_torrents().await.unwrap(); + let torrents = source_database.get_torrents().await.map_err(|source| UpgradeError::Sqlx { + context: "failed to read source torrents", + source, + })?; for torrent in &torrents { // [v2] table torrust_torrents - println!("[v2][torrust_torrents] adding the torrent: {:?} ...", torrent.torrent_id); - - let uploader = source_database.get_user_by_username(&torrent.uploader).await.unwrap(); + info!(torrent_id = torrent.torrent_id, "adding torrent"); - assert!( - uploader.username == torrent.uploader, - "Error copying torrent with id {:?}. - Username (`uploader`) in `torrust_torrents` table does not match `username` in `torrust_users` table", - torrent.torrent_id - ); + let uploader = source_database + .get_user_by_username(&torrent.uploader) + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to read torrent uploader", + source, + })?; + + if uploader.username != torrent.uploader { + return Err(UpgradeError::UploaderMismatch { + torrent_id: torrent.torrent_id, + expected: torrent.uploader.clone(), + actual: uploader.username, + }); + } let filepath = format!("{}/{}.torrent", upload_path, torrent.torrent_id); - let torrent_from_file = - read_torrent_from_file(&filepath).unwrap_or_else(|_| panic!("Error torrent file not found: {filepath:?}")); + let torrent_from_file = read_torrent_from_file(&filepath)?; + let target_torrent = TorrentRecordV2::from_v1_data(torrent, &torrent_from_file.info, &uploader)?; let id = target_database - .insert_torrent(&TorrentRecordV2::from_v1_data(torrent, &torrent_from_file.info, &uploader)) + .insert_torrent(&target_torrent) .await - .unwrap(); - - assert!( - id == torrent.torrent_id, - "Error copying torrent {:?} from source DB to the target DB", - torrent.torrent_id - ); + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert torrent", + source, + })?; + + if id != torrent.torrent_id { + return Err(UpgradeError::IdMismatch { + entity: "torrent", + expected: torrent.torrent_id, + actual: id, + }); + } - println!("[v2][torrust_torrents] torrent with id {:?} added.", torrent.torrent_id); + info!(torrent_id = torrent.torrent_id, "torrent added"); // [v2] table torrust_torrent_files - println!("[v2][torrust_torrent_files] adding torrent files"); + info!(torrent_id = torrent.torrent_id, "adding torrent files"); if torrent_from_file.is_a_single_file_torrent() { // The torrent contains only one file then: // - "path" is NULL // - "md5sum" can be NULL - println!( - "[v2][torrust_torrent_files][single-file-torrent] adding torrent file {:?} with length {:?} ...", - torrent_from_file.info.name, torrent_from_file.info.length, - ); + let length = torrent_from_file.info.length.ok_or(UpgradeError::MissingTorrentField { + torrent_id: torrent.torrent_id, + field: "length", + })?; + info!(torrent_id = torrent.torrent_id, name = %torrent_from_file.info.name, length, "adding single-file torrent entry"); let file_id = target_database .insert_torrent_file_for_torrent_with_one_file( torrent.torrent_id, // TODO: it seems med5sum can be None. Why? When? &torrent_from_file.info.md5sum.clone(), - torrent_from_file.info.length.unwrap(), + length, ) - .await; + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert single-file torrent file", + source, + })?; - println!("[v2][torrust_torrent_files][single-file-torrent] torrent file insert result: {file_id:?}"); + debug!(file_id, torrent_id = torrent.torrent_id, "inserted single-file torrent entry"); } else { // Multiple files are being shared - let files = torrent_from_file.info.files.as_ref().unwrap(); + let files = torrent_from_file + .info + .files + .as_ref() + .ok_or(UpgradeError::MissingTorrentField { + torrent_id: torrent.torrent_id, + field: "files", + })?; for file in files { - println!("[v2][torrust_torrent_files][multiple-file-torrent] adding torrent file: {file:?} ..."); + debug!(torrent_id = torrent.torrent_id, ?file, "adding multi-file torrent entry"); let file_id = target_database .insert_torrent_file_for_torrent_with_multiple_files(torrent, file) - .await; + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert multi-file torrent file", + source, + })?; - println!("[v2][torrust_torrent_files][multiple-file-torrent] torrent file insert result: {file_id:?}"); + debug!(file_id, torrent_id = torrent.torrent_id, "inserted multi-file torrent entry"); } } // [v2] table torrust_torrent_info - println!( - "[v2][torrust_torrent_info] adding the torrent info for torrent id {:?} ...", - torrent.torrent_id - ); + info!(torrent_id = torrent.torrent_id, "adding torrent info"); - let id = target_database.insert_torrent_info(torrent).await; + let id = target_database + .insert_torrent_info(torrent) + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert torrent info", + source, + })?; - println!("[v2][torrust_torrents] torrent info insert result: {id:?}."); + debug!(torrent_info_id = id, torrent_id = torrent.torrent_id, "inserted torrent info"); // [v2] table torrust_torrent_announce_urls - println!( - "[v2][torrust_torrent_announce_urls] adding the torrent announce url for torrent id {:?} ...", - torrent.torrent_id - ); + info!(torrent_id = torrent.torrent_id, "adding torrent announce urls"); - if torrent_from_file.announce_list.is_some() { + if let Some(announce_list) = &torrent_from_file.announce_list { // BEP-0012. Multiple trackers. - println!( - "[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", - torrent.torrent_id - ); - - // flatten the nested vec (this will however remove the) - let announce_urls = torrent_from_file - .announce_list - .clone() - .unwrap() - .into_iter() - .flatten() - .collect::>(); - - for tracker_url in &announce_urls { - println!( - "[v2][torrust_torrent_announce_urls][announce-list] adding the torrent announce url for torrent id {:?} ...", - torrent.torrent_id - ); + for tracker_url in announce_list.iter().flatten() { + debug!(torrent_id = torrent.torrent_id, %tracker_url, "adding announce-list URL"); let announce_url_id = target_database .insert_torrent_announce_url(torrent.torrent_id, tracker_url) - .await; + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert announce-list URL", + source, + })?; - println!( - "[v2][torrust_torrent_announce_urls][announce-list] torrent announce url insert result {announce_url_id:?} ..." - ); + debug!(announce_url_id, torrent_id = torrent.torrent_id, "inserted announce-list URL"); } - } else if torrent_from_file.announce.is_some() { - println!( - "[v2][torrust_torrent_announce_urls][announce] adding the torrent announce url for torrent id {:?} ...", - torrent.torrent_id - ); + } else if let Some(announce) = &torrent_from_file.announce { + debug!(torrent_id = torrent.torrent_id, tracker_url = %announce, "adding announce URL"); let announce_url_id = target_database - .insert_torrent_announce_url(torrent.torrent_id, &torrent_from_file.announce.unwrap()) - .await; - - println!("[v2][torrust_torrent_announce_urls][announce] torrent announce url insert result {announce_url_id:?} ..."); + .insert_torrent_announce_url(torrent.torrent_id, announce) + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert announce URL", + source, + })?; + + debug!(announce_url_id, torrent_id = torrent.torrent_id, "inserted announce URL"); } } - println!("Torrents transferred"); + + info!("torrents transferred"); + + Ok(()) } -pub fn read_torrent_from_file(path: &str) -> Result> { - let contents = fs::read(path)?; +pub fn read_torrent_from_file(path: &str) -> Result { + let contents = fs::read(path).map_err(|source| UpgradeError::Io { + context: "failed to read torrent file", + source, + })?; - match decode_torrent(&contents) { - Ok(torrent) => Ok(torrent), - Err(e) => Err(e), - } + decode_torrent(&contents).map_err(|error| UpgradeError::DecodeTorrent { + path: path.to_string(), + message: error.to_string(), + }) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs index 9021a32f..efef4a48 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/tracker_key_transferrer.rs @@ -1,22 +1,40 @@ use std::sync::Arc; +use tracing::info; + use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::error::UpgradeError; -#[allow(clippy::missing_panics_doc)] -pub async fn transfer_tracker_keys(source_database: Arc, target_database: Arc) { - println!("Transferring tracker keys ..."); +/// Transfer tracker keys from the source database to the target database. +/// +/// # Errors +/// +/// Returns an error if reading, inserting, or validating copied tracker-key data +/// fails. +pub async fn transfer_tracker_keys( + source_database: Arc, + target_database: Arc, +) -> Result<(), UpgradeError> { + info!("transferring tracker keys"); // Transfer table `torrust_tracker_keys` - let tracker_keys = source_database.get_tracker_keys().await.unwrap(); + let tracker_keys = source_database + .get_tracker_keys() + .await + .map_err(|source| UpgradeError::Sqlx { + context: "failed to read source tracker keys", + source, + })?; for tracker_key in &tracker_keys { // [v2] table torrust_tracker_keys - println!( - "[v2][torrust_users] adding the tracker key with id {:?} ...", - tracker_key.key_id + info!( + tracker_key_id = tracker_key.key_id, + user_id = tracker_key.user_id, + "adding tracker key" ); let id = target_database @@ -27,17 +45,25 @@ pub async fn transfer_tracker_keys(source_database: Arc, t tracker_key.valid_until, ) .await - .unwrap(); + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert tracker key", + source, + })?; - assert!( - id == tracker_key.key_id, - "Error copying tracker key {:?} from source DB to the target DB", - tracker_key.key_id - ); + if id != tracker_key.key_id { + return Err(UpgradeError::IdMismatch { + entity: "tracker key", + expected: tracker_key.key_id, + actual: id, + }); + } - println!( - "[v2][torrust_tracker_keys] tracker key with id {:?} added.", - tracker_key.key_id + info!( + tracker_key_id = tracker_key.key_id, + user_id = tracker_key.user_id, + "tracker key added" ); } + + Ok(()) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs index 33ce0d78..3f577d18 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/transferrers/user_transferrer.rs @@ -1,73 +1,81 @@ use std::sync::Arc; +use tracing::info; + use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v1_0_0::SqliteDatabaseV1_0_0; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::sqlite_v2_0_0::SqliteDatabaseV2_0_0; +use crate::upgrades::from_v1_0_0_to_v2_0_0::error::UpgradeError; -#[allow(clippy::missing_panics_doc)] +/// Transfer users and their related profile/authentication records. +/// +/// # Errors +/// +/// Returns an error if reading, inserting, or validating copied user data fails. pub async fn transfer_users( source_database: Arc, target_database: Arc, date_imported: &str, -) { - println!("Transferring users ..."); +) -> Result<(), UpgradeError> { + info!("transferring users"); // Transfer table `torrust_users` - let users = source_database.get_users().await.unwrap(); + let users = source_database.get_users().await.map_err(|source| UpgradeError::Sqlx { + context: "failed to read source users", + source, + })?; for user in &users { // [v2] table torrust_users - println!( - "[v2][torrust_users] adding user with username {:?} and id {:?} ...", - user.username, user.user_id - ); + info!(user_id = user.user_id, username = %user.username, "adding user"); let id = target_database .insert_imported_user(user.user_id, date_imported, user.administrator) .await - .unwrap(); + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert imported user", + source, + })?; - assert!( - id == user.user_id, - "Error copying user {:?} from source DB to the target DB", - user.user_id - ); + if id != user.user_id { + return Err(UpgradeError::IdMismatch { + entity: "user", + expected: user.user_id, + actual: id, + }); + } - println!("[v2][torrust_users] user: {:?} {:?} added.", user.user_id, user.username); + info!(user_id = user.user_id, username = %user.username, "user added"); // [v2] table torrust_user_profiles - println!( - "[v2][torrust_user_profiles] adding user profile for user with username {:?} and id {:?} ...", - user.username, user.user_id - ); + info!(user_id = user.user_id, username = %user.username, "adding user profile"); target_database .insert_user_profile(user.user_id, &user.username, &user.email, user.email_verified) .await - .unwrap(); + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert user profile", + source, + })?; - println!( - "[v2][torrust_user_profiles] user profile added for user with username {:?} and id {:?}.", - user.username, user.user_id - ); + info!(user_id = user.user_id, username = %user.username, "user profile added"); // [v2] table torrust_user_authentication - println!( - "[v2][torrust_user_authentication] adding password hash ({:?}) for user id ({:?}) ...", - user.password, user.user_id - ); + info!(user_id = user.user_id, "adding user authentication"); target_database .insert_user_password_hash(user.user_id, &user.password) .await - .unwrap(); + .map_err(|source| UpgradeError::Sqlx { + context: "failed to insert user authentication", + source, + })?; - println!( - "[v2][torrust_user_authentication] password hash ({:?}) added for user id ({:?}).", - user.password, user.user_id - ); + info!(user_id = user.user_id, "user authentication added"); } + + Ok(()) } diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 5984e650..b3946f03 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -18,10 +18,8 @@ //! cargo run --bin upgrade ./data.db ./data_v2.db ./uploads //! ``` //! -//! ADR-T-010 classifies this as a side-effect command: the target contract is -//! empty stdout and JSON diagnostics on stderr. The current implementation is a -//! legacy output gap until migration, so automation should branch on exit status -//! rather than parsing current plain text. +//! ADR-T-010 classifies this as a side-effect command: stdout remains empty and +//! diagnostics are JSON records on stderr. //! //! This command was created to help users to migrate from version `v1.0.0` to //! `v2.0.0`. The main changes in version `v2.0.0` were: @@ -51,20 +49,18 @@ //! //! //! If you want more information about this command you can read the [issue 56](https://github.com/torrust/torrust-index/issues/56). -use std::env; use std::time::SystemTime; use chrono::prelude::{DateTime, Utc}; -use text_colorizer::Colorize; +use tracing::info; use crate::upgrades::from_v1_0_0_to_v2_0_0::databases::{current_db, migrate_target_database, new_db, truncate_target_database}; +use crate::upgrades::from_v1_0_0_to_v2_0_0::error::UpgradeError; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::category_transferrer::transfer_categories; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::torrent_transferrer::transfer_torrents; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::tracker_key_transferrer::transfer_tracker_keys; use crate::upgrades::from_v1_0_0_to_v2_0_0::transferrers::user_transferrer::transfer_users; -const NUMBER_OF_ARGUMENTS: usize = 3; - #[derive(Debug)] pub struct Arguments { /// The source database in version v1.0.0 we want to migrate @@ -75,72 +71,48 @@ pub struct Arguments { pub upload_path: String, } -fn print_usage() { - eprintln!( - "{} - migrates date from version v1.0.0 to v2.0.0. - - cargo run --bin upgrade SOURCE_DB_FILE TARGET_DB_FILE TORRENT_UPLOAD_DIR - - For example: - - cargo run --bin upgrade ./data.db ./data_v2.db ./uploads - - ", - "Upgrader".green() - ); -} - -fn parse_args() -> Arguments { - let args: Vec = env::args().skip(1).collect(); - - if args.len() != NUMBER_OF_ARGUMENTS { - eprintln!( - "{} wrong number of arguments: expected {}, got {}", - "Error".red().bold(), - NUMBER_OF_ARGUMENTS, - args.len() - ); - print_usage(); - } - - Arguments { - source_database_file: args[0].clone(), - target_database_file: args[1].clone(), - upload_path: args[2].clone(), - } -} - -pub async fn run() { +/// Run the v1.0.0 to v2.0.0 upgrade with the current timestamp. +/// +/// # Errors +/// +/// Returns an error if any database setup, migration, truncation, or transfer +/// step fails. +pub async fn run(args: Arguments) -> Result<(), UpgradeError> { let now = datetime_iso_8601(); - upgrade(&parse_args(), &now).await; + upgrade(&args, &now).await } -pub async fn upgrade(args: &Arguments, date_imported: &str) { +/// Upgrade data from v1.0.0 to v2.0.0 using a caller-supplied import timestamp. +/// +/// # Errors +/// +/// Returns an error if any database setup, migration, truncation, or transfer +/// step fails. +pub async fn upgrade(args: &Arguments, date_imported: &str) -> Result<(), UpgradeError> { // Get connection to the source database (current DB in settings) - let source_database = current_db(&args.source_database_file).await; + let source_database = current_db(&args.source_database_file).await?; // Get connection to the target database (new DB we want to migrate the data) - let target_database = new_db(&args.target_database_file).await; + let target_database = new_db(&args.target_database_file).await?; - println!("Upgrading data from version v1.0.0 to v2.0.0 ..."); + info!("upgrading data from version v1.0.0 to v2.0.0"); - migrate_target_database(target_database.clone()).await; - truncate_target_database(target_database.clone()).await; + migrate_target_database(target_database.clone()).await?; + truncate_target_database(target_database.clone()).await?; - transfer_categories(source_database.clone(), target_database.clone()).await; - transfer_users(source_database.clone(), target_database.clone(), date_imported).await; - transfer_tracker_keys(source_database.clone(), target_database.clone()).await; - transfer_torrents(source_database.clone(), target_database.clone(), &args.upload_path).await; + transfer_categories(source_database.clone(), target_database.clone()).await?; + transfer_users(source_database.clone(), target_database.clone(), date_imported).await?; + transfer_tracker_keys(source_database.clone(), target_database.clone()).await?; + transfer_torrents(source_database.clone(), target_database.clone(), &args.upload_path).await?; - println!("Upgrade data from version v1.0.0 to v2.0.0 finished!\n"); + info!("upgrade data from version v1.0.0 to v2.0.0 finished"); - eprintln!( - "{}\nWe recommend you to run the command to import torrent statistics for all torrents manually. \ - If you do not do it the statistics will be imported anyway during the normal execution of the program. \ - You can import statistics manually with:\n {}", - "SUGGESTION: \n".yellow(), - "cargo run --bin import_tracker_statistics".yellow() + info!( + command = "cargo run --bin import_tracker_statistics", + "run the tracker statistics importer manually if you want all torrent statistics imported before normal execution" ); + + Ok(()) } /// Current datetime in ISO8601 without time zone. diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs index c468ae7c..f3eb45a1 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/transferrer_testers/torrent_transferrer_tester.rs @@ -123,7 +123,7 @@ impl TorrentTester { assert_eq!(imported_torrent.is_bep_30, i64::from(torrent_file.info.is_bep_30())); assert_eq!( imported_torrent.date_uploaded, - convert_timestamp_to_datetime(torrent.upload_date) + convert_timestamp_to_datetime(torrent.upload_date).expect("fixture timestamp must be valid") ); } diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index 7344f7f2..3a846ea7 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -83,7 +83,8 @@ async fn upgrades_data_from_version_v1_0_0_to_v2_0_0() { }, &execution_time, ) - .await; + .await + .expect("upgrade must succeed for fixture data"); // Assertions for data transferred to the new database in version v2.0.0 category_tester.assert_data_in_target_db().await; diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index f57a01b2..a90cb14d 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -7,7 +7,7 @@ To upgrade from version `v1.0.0` to `v2.0.0` you have to follow these steps: - Back up your current database and the `uploads` folder. You can find which database and upload folder are you using in the `Config.toml` file in the root folder of your installation. - Set up a local environment exactly as you have it in production with your production data (DB and torrents folder). - Run the application locally with: `cargo run`. -- Execute the upgrader command: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads` +- Execute the upgrader command: `cargo run --bin upgrade -- ./data.db ./data_v2.db ./uploads` - A new SQLite file should have been created in the root folder: `data_v2.db` - Stop the running application and change the DB configuration to use the newly generated configuration: @@ -22,14 +22,19 @@ connect_url = "sqlite://data_v2.db?mode=rwc" ## Command Output -`upgrade` is an ADR-T-010 side-effect command: its target contract is empty -stdout, with status and error diagnostics emitted as JSON records on stderr. -Automation should branch on the process exit code and must not parse plain text -from stdout. +`upgrade` is an ADR-T-010 side-effect command: stdout is empty on success and on +failure. Status, help, version, usage errors, migration failures, and panic +diagnostics are emitted as JSON records on stderr. Stderr is NDJSON when the +command emits more than one record. -The current binary is still a legacy output gap until its ADR-T-010 migration -stage lands. Treat any current plain-text diagnostics as temporary and avoid -building scripts around them. +Automation should branch on the process exit code and parse stderr as JSON when +it needs diagnostics. Usage failures, including invalid argv, exit with code 2; +runtime upgrade failures exit with code 1. + +```sh +cargo run --bin upgrade -- ./data.db ./data_v2.db ./uploads 2>upgrade.ndjson +jq . upgrade.ndjson +``` ## Tests From 0dd220b20d7f268a4c5874378fda2f86a1b08a0d Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 13:23:17 +0200 Subject: [PATCH 07/13] feat(cli)!: complete ADR-T-010 shared-library cleanup Route command-reachable server library diagnostics through the JSON logging boundary instead of direct stream output. Shutdown grace-period notices now use structured tracing, and mail template initialization and render failures now return typed errors for caller-side JSON diagnostic reporting. Document the stage-seven ADR-T-010 status across the README, container docs, crate docs, changelog, and rollout plan. BREAKING CHANGE: command-reachable shared libraries no longer print mailer or shutdown diagnostics directly, and mail-template initialization failures are reported through callers instead of exiting in the mailer library. --- CHANGELOG.md | 10 ++-- README.md | 6 +++ docs/containers.md | 5 ++ ...10-command-line-output-conformance-plan.md | 33 ++++++++---- src/lib.rs | 10 ++++ src/mailer.rs | 51 ++++++++++++------- src/web/api/server/signals.rs | 11 ++-- 7 files changed, 91 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b7a6dc..32f7d72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,11 +123,15 @@ error system (ADR-T-006), MSRV raised to 1.88. seeder, and upgrade paths now emit structured tracing diagnostics and propagate command failures instead of printing plain text or relying on panic output. +- Command-reachable shared libraries no longer emit raw stream output for + shutdown and mail-template diagnostics. Server shutdown notices now go through + structured tracing, and mail template initialization failures are propagated + to callers instead of printing or exiting from the mailer library. - Operator documentation now describes the ADR-T-010 migration state: helper binaries have the JSON stdout contract, the server emits JSON tracing on - stderr, root Rust command migrations through stage six have their JSON stream - contracts, and the container entry script remains a legacy output gap until - its rollout stage lands. + stderr, root Rust command migrations and shared-library cleanup through stage + seven have their JSON stream contracts, and the container entry script remains + a legacy output gap until its rollout stage lands. ### ADR-T-009 — Container infrastructure refactor diff --git a/README.md b/README.md index 9eb6d5c8..50cc8e79 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,12 @@ the default filter, and a non-empty `RUST_LOG` environment variable overrides that default. Panics that cross the binary boundary are reported as ADR-T-010 JSON control-plane records on stderr. +Command-reachable server libraries use the same diagnostic path. Shutdown +grace-period notices are structured tracing records, and mail-template +initialization or rendering failures are propagated to callers for JSON +diagnostic reporting instead of being printed or exiting from the mailer +library. + The shared helper infrastructure now wraps `clap` help, version, and usage errors as JSON control-plane records on stderr, installs a JSON-only panic hook, and uses JSON tracing on stderr. The container helper binaries emit exactly one diff --git a/docs/containers.md b/docs/containers.md index a0570985..61be78c4 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -413,6 +413,11 @@ configured `[logging].threshold` selects the default filter, and a non-empty `RUST_LOG` environment variable overrides that default. The server does not refuse terminal stdout, because it does not emit stdout result data. +Command-reachable server libraries follow that same stream contract. Shutdown +grace-period notices are structured tracing records, and mail-template +initialization or rendering failures are returned to callers for JSON diagnostic +reporting rather than printed or handled by exiting from the mailer library. + Container helper binaries follow the ADR-T-010 stdout/stderr split. When they emit result data, stdout is exactly one JSON object with a trailing newline and a top-level `schema` field. Diagnostics, help, version output, argv errors, TTY diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index a620a3d1..7e36b70a 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -11,8 +11,8 @@ code, documentation, and regression tests. ## Current Implementation Status -Stages 1 through 6 have landed for the shared Rust helper path and root command -migrations: +Stages 1 through 7 have landed for the shared Rust helper path, root command +migrations, and command-reachable shared-library cleanup: - Stage 1 fixed the shared control-plane record shape, baseline exit classes, helper stdout schemas, and redaction helpers. @@ -39,9 +39,13 @@ migrations: stdout side-effect contract, structured tracing diagnostics, and propagated command errors. The command-reachable tracker statistics and upgrade modules no longer emit raw stream output or terminal color formatting. +- Stage 7 removed the remaining raw stream output from command-reachable shared + libraries. Server shutdown notices now use structured tracing diagnostics, + and mail template initialization errors are returned to callers for JSON + diagnostic reporting instead of printing or exiting from the mailer library. -The container entry script, remaining shared-library cleanup, and regression -guards remain future rollout stages unless their sections below say otherwise. +The container entry script and regression guards remain future rollout stages +unless their sections below say otherwise. ## Goal @@ -314,6 +318,11 @@ Required changes: Update `src/main.rs` and the startup path used by `torrust-index`. +Stage 7 status: implemented for command-reachable shared-library output cleanup. +`src/web/api/server/signals.rs` now reports shutdown notices through structured +tracing, and `src/mailer.rs` returns template initialization errors to callers +instead of printing or exiting from the library. + Required changes: - Install the JSON panic hook at process start. @@ -473,11 +482,12 @@ Update operator documentation after the behavior changes. Current documentation status: the shared contract shape, helper stdout result schemas, expanded Rust CLI infrastructure, helper-binary wiring state, stage-four server logging / root `ExitCode` boundary state, the stage-five -`parse_torrent` / `create_test_torrent` migration, and the stage-six root -maintenance command migration have been documented. The container entry script -is still a legacy output gap until its rollout stage lands; its documentation -should describe the ADR-T-010 target contract without promising behaviour it -does not yet implement. +`parse_torrent` / `create_test_torrent` migration, the stage-six root +maintenance command migration, and the stage-seven command-reachable +shared-library cleanup have been documented. The container entry script is still +a legacy output gap until its rollout stage lands; its documentation should +describe the ADR-T-010 target contract without promising behaviour it does not +yet implement. Documentation maintenance requirements: @@ -549,10 +559,11 @@ summarizing results, following the repository test-running convention. ## Rollout Order -Current status: steps 1 through 6 have landed. Documentation and changelog +Current status: steps 1 through 7 have landed. Documentation and changelog entries for the shared-helper stages, the stage-four root logging / binary-boundary rollout, the stage-five root binary migration, and the stage-six -root maintenance command migration have been updated. Later operator-visible +root maintenance command migration, and the stage-seven shared-library cleanup +have been updated. Later operator-visible migrations still need their own documentation and changelog updates when they land. diff --git a/src/lib.rs b/src/lib.rs index ff01faf6..4d7a09f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ //! - [Development](#development) //! - [Configuration](#configuration) //! - [Usage](#usage) +//! - [Command-Line Output](#command-line-output) //! - [API](#api) //! - [Tracker Statistics Importer](#tracker-statistics-importer) //! - [Upgrader](#upgrader) @@ -225,6 +226,15 @@ //! //! # Usage //! +//! ## Command-Line Output +//! +//! ADR-T-010 classifies the `torrust-index` server as a no-stdout command: +//! stdout remains empty, and server diagnostics are JSON records on stderr. +//! Command-reachable libraries use the same path for operator-facing messages; +//! shutdown notices are structured tracing records, and mail-template failures +//! are propagated to callers for JSON diagnostic reporting instead of being +//! printed or handled by exiting from the mailer library. +//! //! ## API //! //! Running the index with the default configuration will expose the REST API on port 3001: diff --git a/src/mailer.rs b/src/mailer.rs index 926290e1..34166c9e 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -7,6 +7,7 @@ use lettre::transport::smtp::authentication::{Credentials, Mechanism}; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use serde_json::value::{Value, to_value}; use tera::{Context, Tera, try_get_value}; +use thiserror::Error; use tracing::error; use crate::config::Configuration; @@ -19,7 +20,24 @@ use crate::web::api::server::v1::routes::API_VERSION_URL_PREFIX; /// Default verify-email template, compiled into the binary. const VERIFY_EMAIL_DEFAULT: &str = include_str!("../templates/verify.html"); -pub static TEMPLATES: LazyLock = LazyLock::new(|| { +pub(crate) static TEMPLATES: LazyLock> = LazyLock::new(build_templates); + +#[derive(Debug, Error)] +pub(crate) enum MailTemplateError { + #[error("failed to read templates/verify.html: {source}")] + ReadOverride { source: std::io::Error }, + + #[error("failed to register email template: {source}")] + RegisterTemplate { source: tera::Error }, + + #[error("failed to initialize email templates: {message}")] + Initialize { message: String }, + + #[error("failed to render email template: {source}")] + Render { source: tera::Error }, +} + +fn build_templates() -> Result { let mut tera = Tera::default(); // Allow deployers to override the template by placing a file at @@ -28,24 +46,16 @@ pub static TEMPLATES: LazyLock = LazyLock::new(|| { let template = match std::fs::read_to_string("templates/verify.html") { Ok(contents) => contents, Err(err) if err.kind() == ErrorKind::NotFound => VERIFY_EMAIL_DEFAULT.to_string(), - Err(err) => { - error!(error = %err, "Failed to read templates/verify.html"); - ::std::process::exit(1); - } + Err(source) => return Err(MailTemplateError::ReadOverride { source }), }; - match tera.add_raw_template("html_verify_email", &template) { - Ok(()) => {} - Err(e) => { - println!("Parsing error(s): {e}"); - ::std::process::exit(1); - } - } + tera.add_raw_template("html_verify_email", &template) + .map_err(|source| MailTemplateError::RegisterTemplate { source })?; tera.autoescape_on(vec![".html", ".sql"]); tera.register_filter("do_nothing", do_nothing_filter); - tera -}); + Ok(tera) +} /// This function is a dummy filter for tera. /// @@ -155,8 +165,8 @@ impl Service { } pub(crate) fn build_letter(verification_url: &str, username: &str, builder: MessageBuilder) -> Result { - let (plain_body, html_body) = build_content(verification_url, username).map_err(|e| { - tracing::error!("{e}"); + let (plain_body, html_body) = build_content(verification_url, username).map_err(|error| { + error!(%error, "failed to build verification email content"); UserError::InternalServerError })?; @@ -178,7 +188,7 @@ pub(crate) fn build_letter(verification_url: &str, username: &str, builder: Mess .expect("the `multipart` builder had an error")) } -pub(crate) fn build_content(verification_url: &str, username: &str) -> Result<(String, String), tera::Error> { +pub(crate) fn build_content(verification_url: &str, username: &str) -> Result<(String, String), MailTemplateError> { let plain_body = format!( " Welcome to Torrust, {username}! @@ -192,7 +202,12 @@ pub(crate) fn build_content(verification_url: &str, username: &str) -> Result<(S let mut context = Context::new(); context.insert("verification", &verification_url); context.insert("username", &username); - let html_body = TEMPLATES.render("html_verify_email", &context)?; + let templates = TEMPLATES.as_ref().map_err(|error| MailTemplateError::Initialize { + message: error.to_string(), + })?; + let html_body = templates + .render("html_verify_email", &context) + .map_err(|source| MailTemplateError::Render { source })?; Ok((plain_body, html_body)) } diff --git a/src/web/api/server/signals.rs b/src/web/api/server/signals.rs index e19a3689..4c1f457b 100644 --- a/src/web/api/server/signals.rs +++ b/src/web/api/server/signals.rs @@ -26,10 +26,15 @@ pub async fn graceful_shutdown( ) { shutdown_signal_with_message(rx_halt, message).await; + let graceful_shutdown_timeout = Duration::from_secs(90); + info!("Sending graceful shutdown signal"); - handle.graceful_shutdown(Some(Duration::from_secs(90))); + handle.graceful_shutdown(Some(graceful_shutdown_timeout)); - println!("!! shuting down in 90 seconds !!"); + info!( + grace_period_seconds = graceful_shutdown_timeout.as_secs(), + "shutting down gracefully" + ); loop { sleep(Duration::from_secs(1)).await; @@ -38,7 +43,7 @@ pub async fn graceful_shutdown( } } -/// Same as `shutdown_signal()`, but shows a message when it resolves. +/// Same as `shutdown_signal()`, but logs a message when it resolves. pub async fn shutdown_signal_with_message(rx_halt: tokio::sync::oneshot::Receiver, message: String) { shutdown_signal(rx_halt).await; From 4089926e12883ede983b1ba6f55b5fcd21b82935 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 13:59:20 +0200 Subject: [PATCH 08/13] feat(cli)!: migrate container entry script to ADR-T-010 Move the container entry script onto the ADR-T-010 no-stdout orchestration contract. Startup validation failures, status notices, debug phase records, utility failures, jq failures, and unexpected shell exits now emit JSON/NDJSON control-plane records on stderr instead of plain text or shell trace output. Add POSIX shell JSON emitters, checked utility wrappers, jq read/write helpers, append helpers, phase tracking, and an unexpected-exit trap so controlled failures capture stderr inside JSON fields while helper stdout stays internal to command substitutions. Update the entry-script host tests to parse stderr as JSON records, add shared test assertions for the entry-script record shape, and document the stage-eight container migration across the README, container docs, changelog, and ADR-T-010 rollout plan. BREAKING CHANGE: the container entry script now emits JSON/NDJSON records on stderr before su-exec and DEBUG=1 emits JSON phase diagnostics instead of enabling set -x. Automation that scraped legacy startup text or shell tracing must switch to exit codes and JSON stderr parsing. --- CHANGELOG.md | 26 +- Cargo.lock | 1 + Containerfile | 2 +- README.md | 7 + docs/containers.md | 62 ++-- ...10-command-line-output-conformance-plan.md | 41 ++- packages/index-entry-script/Cargo.toml | 1 + packages/index-entry-script/src/lib.rs | 67 ++++- .../index-entry-script/tests/seed_sqlite.rs | 44 ++- .../tests/validate_auth_keys.rs | 21 +- share/container/entry_script_lib_sh | 279 +++++++++++++++--- share/container/entry_script_sh | 138 +++++---- 12 files changed, 502 insertions(+), 187 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f7d72e..e3094f8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,11 @@ error system (ADR-T-006), MSRV raised to 1.88. stdout-producing commands refuse direct terminal stdout. `parse_torrent` now emits JSON result data on stdout. `create_test_torrent`, `import_tracker_statistics`, `seeder`, and `upgrade` now keep stdout empty - while reporting status and diagnostics as JSON on stderr. Scripts that scraped - their previous plain-text output must switch to exit codes and JSON/NDJSON - stderr parsing. + while reporting status and diagnostics as JSON on stderr. The container entry + script also reports validation failures, status records, utility failures, and + debug phase records as JSON/NDJSON on stderr instead of plain text or shell + trace output. Scripts that scraped previous plain-text command or startup + output must switch to exit codes and JSON/NDJSON stderr parsing. - The `torrust-index` server's application logs now use JSON records on stderr instead of the previous human-formatted tracing output. Log consumers should parse stderr as NDJSON or pipe it through a JSON viewer. @@ -127,11 +129,17 @@ error system (ADR-T-006), MSRV raised to 1.88. shutdown and mail-template diagnostics. Server shutdown notices now go through structured tracing, and mail template initialization failures are propagated to callers instead of printing or exiting from the mailer library. +- The container entry script now follows ADR-T-010 during its pre-`su-exec` + orchestration phase. It keeps stdout empty except for helper stdout captured + internally in command substitutions, emits JSON control-plane records on + stderr, checks for `jq` before JSON-dependent helpers run, emits `DEBUG=1` + phase records instead of enabling `set -x`, and wraps controlled utility + failures with captured stderr fields. - Operator documentation now describes the ADR-T-010 migration state: helper binaries have the JSON stdout contract, the server emits JSON tracing on - stderr, root Rust command migrations and shared-library cleanup through stage - seven have their JSON stream contracts, and the container entry script remains - a legacy output gap until its rollout stage lands. + stderr, root Rust command migrations and shared-library cleanup have their + JSON stream contracts, and the container entry script has its stage-eight JSON + stderr contract documented. ### ADR-T-009 — Container infrastructure refactor @@ -202,7 +210,7 @@ error system (ADR-T-006), MSRV raised to 1.88. - `EXPOSE ${IMPORTER_API_PORT}/tcp` in Containerfile; port 3002 mapped in compose. - `restart: unless-stopped` on index and tracker compose services. -- `DEBUG=1` env-var gate for entry-script shell tracing (`set -x`). +- `DEBUG=1` env-var gate for entry-script JSON phase diagnostics. - `#[doc(hidden)] pub mod test_helpers` in `torrust-index-config` exposing `PLACEHOLDER_TOML` and `placeholder_settings()` — single source of truth for the ~40 tests across both crates that @@ -467,8 +475,8 @@ error system (ADR-T-006), MSRV raised to 1.88. - Dev-only ports (MySQL 3306, tracker 6969/7070/1212, mailcatcher 1025/1080) no longer bind to `0.0.0.0`; bound to `127.0.0.1`. -- Entry script `set -x` gated behind `DEBUG=1` to avoid leaking - env vars into logs. +- Entry script debug mode now emits structured JSON phase records instead of + enabling `set -x`, avoiding shell-trace leakage of env vars into logs. - Compose credentials annotated as DEV-ONLY with TODO for Docker secrets migration (ADR-T-009 §S1). diff --git a/Cargo.lock b/Cargo.lock index 0c6594ee..5392b6fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4274,6 +4274,7 @@ dependencies = [ name = "torrust-index-entry-script" version = "4.0.0-develop" dependencies = [ + "serde_json", "tempfile", ] diff --git a/Containerfile b/Containerfile index 0856c419..42705b74 100644 --- a/Containerfile +++ b/Containerfile @@ -12,7 +12,7 @@ RUN cargo binstall --no-confirm --locked cargo-chef cargo-nextest FROM rust:slim-trixie AS tester WORKDIR /tmp -RUN apt-get update; apt-get install -y curl sqlite3; apt-get autoclean +RUN apt-get update; apt-get install -y curl jq sqlite3; apt-get autoclean RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/v1.18.1/install-from-binstall-release.sh | bash RUN cargo binstall --no-confirm --locked cargo-nextest imdl diff --git a/README.md b/README.md index 50cc8e79..c1043585 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,13 @@ For helper diagnostics, a non-empty `RUST_LOG` environment variable takes precedence over `--debug`; otherwise `--debug` raises the default diagnostic filter to debug. +The container entry script is also a no-stdout orchestration command. It captures +helper stdout internally, keeps its own stdout empty before `su-exec`, and emits +startup validation failures, status records, utility failures, and `DEBUG=1` +phase diagnostics as JSON records on stderr. Use `docker logs ... 2>&1` or your +runtime's stderr capture and parse those lines as NDJSON when automation needs +startup diagnostics. + Two root diagnostic commands have also been migrated. `parse_torrent` is a stdout-result command: it emits one JSON object containing `schema`, `torrent`, `original_v1_info_hash`, and `input_byte_length`, and it refuses direct terminal diff --git a/docs/containers.md b/docs/containers.md index 61be78c4..c035f05c 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -445,11 +445,17 @@ data and therefore do not trigger TTY refusal; they write JSON control-plane records to stderr and exit with code 0. The container entry script captures helper stdout internally and does not -forward it to the terminal. Its own diagnostics are still part of the ADR-T-010 -migration backlog; until that rollout stage lands, treat any plain-text entry -script stderr as a legacy compatibility gap rather than a new output contract. -Container startup logs may therefore contain legacy entry-script lines before -the Rust server begins emitting JSON tracing records. +forward it to the terminal. Before it execs the application, the entry script is +a no-stdout orchestration command: validation failures, status notices, expected +utility failures, `jq` parsing failures, unexpected shell exits, and `DEBUG=1` +phase records are emitted on stderr as one JSON object per line. The records use +the shared `schema`, `command`, `kind`, `message`, and `fields` shape; the +entry-script command name is `torrust-index-entry-script`. + +Expected utility stderr captured by the script is carried inside JSON fields +instead of being forwarded as top-level stream text. Helper stdout remains inside +command substitutions. File writes to `/etc/motd` and `/etc/profile` are not +stream output. ### Healthcheck (both targets) @@ -485,9 +491,9 @@ the entry script needs at first boot: `su-exec` is a separate root-only binary at `/bin/su-exec`, not a busybox applet. `jq` is a separate root-only binary at -`/usr/bin/jq` used by the entry script's auth-keypair -bootstrap. None of these are reachable by the unprivileged -`torrust` user. +`/usr/bin/jq` used by the entry script's JSON diagnostics, +config-probe parsing, and auth-keypair bootstrap. None of +these are reachable by the unprivileged `torrust` user. There is no `/busybox/` directory in the release image — the full busybox applet tree from the upstream `:debug` @@ -513,22 +519,24 @@ interactive shell as the application user. ### Entry Script Debugging The container entry script does not produce verbose output by default. -To enable shell tracing (`set -x`) for startup troubleshooting, set the -`DEBUG` environment variable: +To emit JSON phase records for startup troubleshooting, set the `DEBUG` +environment variable: ```sh --env DEBUG=1 ``` +Debug records are written to stderr as ADR-T-010 JSON status records with +`fields.level = "debug"`. The script no longer enables shell tracing with +`set -x`, so automation should parse stderr as NDJSON rather than scrape shell +trace lines. + The entry script also runs under `set -eu` (POSIX `errexit` + `nounset`): any unchecked command failure aborts startup immediately, and references to unset variables are treated as errors. This converts a class of silent-misconfiguration bugs into loud, actionable startup failures. -ADR-T-010 will replace this legacy shell tracing path with explicit JSON debug -records in a later rollout stage. Do not parse `set -x` output in automation. - ### Entry Script Contract Per ADR-T-009 §7, the entry script reads its configuration in @@ -625,10 +633,12 @@ in the repo wires both overrides for the local dev workflow Both runtime images ship a root-only `/usr/bin/jq` (mode `0500 root:root`, sourced from a pristine `rust:slim-trixie` `jq_donor` build stage in the Containerfile). It is invoked -only during the entry script's pre-`su-exec` phase to parse -the config probe's JSON output and the auth-keypair helper's -JSON output. The unprivileged `torrust` user has no access -to `/usr/bin/jq` after privilege drop. +only during the entry script's pre-`su-exec` phase to emit +properly escaped JSON diagnostics, parse the config probe's +JSON output, and split the auth-keypair helper's JSON output. +If `jq` is unavailable, the script emits a fixed minimal JSON +diagnostic before exiting. The unprivileged `torrust` user has +no access to `/usr/bin/jq` after privilege drop. Operators who run the helpers manually should use the same pattern: pipe stdout result data to `jq`, redirect it to a file, or capture it from another process. @@ -636,16 +646,16 @@ Direct terminal stdout is refused by design. #### Sourced Shell Library -The entry script's pure helper functions (`inst`, -`key_configured`, `validate_auth_keys`, `seed_sqlite`) live -in a separate POSIX `sh` library shipped at +The entry script's reusable POSIX `sh` helpers live in a +separate library shipped at `/usr/local/lib/torrust/entry_script_lib_sh` (mode `0444 root:root`, sourced — not exec'd). Splitting them out lets the workspace test crate [`packages/index-entry-script/`](../packages/index-entry-script/) -drive each helper through a host `sh` subprocess and assert -the exit-code / stderr contracts of every branch of -ADR-T-009 §7.1's auth-key invariants and §7.2's seeding -outcomes. The library has no top-level side effects, so -sourcing it from either the entry script or a test harness -is safe. +drive helpers such as `validate_auth_keys` and `seed_sqlite` +through a host `sh` subprocess and assert their exit-code and +JSON stderr contracts. The same library owns the entry script's +JSON control-plane emitters, checked utility wrappers, `jq` +read/write helpers, and unexpected-exit trap. It has no top-level +side effects, so sourcing it from either the entry script or a test +harness is safe. diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index 7e36b70a..bd9ed6ed 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -11,8 +11,8 @@ code, documentation, and regression tests. ## Current Implementation Status -Stages 1 through 7 have landed for the shared Rust helper path, root command -migrations, and command-reachable shared-library cleanup: +Stages 1 through 8 have landed for the shared Rust helper path, root command +migrations, command-reachable shared-library cleanup, and container entry script: - Stage 1 fixed the shared control-plane record shape, baseline exit classes, helper stdout schemas, and redaction helpers. @@ -43,9 +43,14 @@ migrations, and command-reachable shared-library cleanup: libraries. Server shutdown notices now use structured tracing diagnostics, and mail template initialization errors are returned to callers for JSON diagnostic reporting instead of printing or exiting from the mailer library. +- Stage 8 migrated the container entry script and its host-side helper tests. + Shell diagnostics now use JSON stderr control-plane records, debug mode emits + explicit JSON phase records instead of `set -x`, expected validation failures + are reported as JSON diagnostics, and utility failures controlled by the + script are captured and re-emitted as JSON fields. -The container entry script and regression guards remain future rollout stages -unless their sections below say otherwise. +Documentation updates and regression guards remain future rollout stages unless +their sections below say otherwise. ## Goal @@ -446,6 +451,13 @@ Required changes for `src/bin/upgrade.rs` and the v1-to-v2 upgrade modules: Update `share/container/entry_script_sh` and `share/container/entry_script_lib_sh`. +Stage 8 status: implemented. The shell entrypoint now checks for `jq`, emits +JSON diagnostics and debug phase records on stderr, wraps validation failures in +shared control-plane records, captures expected utility stderr where the script +controls the utility invocation, and keeps helper stdout inside command +substitutions. The `packages/index-entry-script` host-side tests now parse and +assert JSON stderr records for validation failures and status branches. + Required changes: - Add POSIX-shell JSON diagnostic helpers, for example `json_log` and @@ -483,11 +495,11 @@ Current documentation status: the shared contract shape, helper stdout result schemas, expanded Rust CLI infrastructure, helper-binary wiring state, stage-four server logging / root `ExitCode` boundary state, the stage-five `parse_torrent` / `create_test_torrent` migration, the stage-six root -maintenance command migration, and the stage-seven command-reachable -shared-library cleanup have been documented. The container entry script is still -a legacy output gap until its rollout stage lands; its documentation should -describe the ADR-T-010 target contract without promising behaviour it does not -yet implement. +maintenance command migration, the stage-seven command-reachable shared-library +cleanup, and the stage-eight container entry-script migration have been +documented. Later documentation work should focus on regression guards or future +command-specific contracts rather than re-describing the stage-eight rollout as +pending. Documentation maintenance requirements: @@ -559,13 +571,13 @@ summarizing results, following the repository test-running convention. ## Rollout Order -Current status: steps 1 through 7 have landed. Documentation and changelog +Current status: steps 1 through 9 have landed. Documentation and changelog entries for the shared-helper stages, the stage-four root logging / binary-boundary rollout, the stage-five root binary migration, and the stage-six root maintenance command migration, and the stage-seven shared-library cleanup -have been updated. Later operator-visible -migrations still need their own documentation and changelog updates when they -land. +have been updated. The stage-eight container entry-script documentation and +changelog entries have also been updated. Later operator-visible migrations +still need their own documentation and changelog updates when they land. 1. Finalize the shared control-plane record shape, command-specific result schema details, exit-code mapping, and redaction rules. @@ -587,6 +599,3 @@ land. - The exact exit-code taxonomy for root maintenance commands beyond the baseline `success`, `failure`, and `usage` classes. Existing helper-specific exit codes should remain stable unless a command-specific contract says otherwise. -- How strict the container entry script can be with external utility stderr. Full - conformance requires expected failures to be captured and re-emitted as JSON; - unexpected process crashes may still need a pragmatic trap-based fallback. diff --git a/packages/index-entry-script/Cargo.toml b/packages/index-entry-script/Cargo.toml index 1a1649ea..adb34a79 100644 --- a/packages/index-entry-script/Cargo.toml +++ b/packages/index-entry-script/Cargo.toml @@ -14,6 +14,7 @@ version.workspace = true path = "src/lib.rs" [dependencies] +serde_json = "1" tempfile = "3" [lints] diff --git a/packages/index-entry-script/src/lib.rs b/packages/index-entry-script/src/lib.rs index faa2455c..51151dc3 100644 --- a/packages/index-entry-script/src/lib.rs +++ b/packages/index-entry-script/src/lib.rs @@ -7,7 +7,7 @@ //! The crate ships **no runtime code** of its own — it exists //! purely as a home for `tests/` that invoke `sh` as a //! subprocess against the shell library and assert exit -//! codes / stderr contents. This keeps the tests inside +//! codes / JSON stderr records. This keeps the tests inside //! `cargo test --workspace` (so CI runs them automatically) //! while the helpers themselves remain POSIX `sh`, since they //! must run inside the distroless busybox runtime where Rust @@ -36,12 +36,14 @@ //! `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__*_PATH` plus key //! materialisation against the real generator. //! -//! Both belong in the container e2e suite (Phase 8 / 9). +//! Both belong in the container e2e suite. use std::path::PathBuf; use std::process::{Command, Output, Stdio}; use std::sync::OnceLock; +use serde_json::Value; + /// Contents of the shell library, embedded at compile time. /// /// Embedding sidesteps the cargo-nextest archive → @@ -158,3 +160,64 @@ pub fn run_sh_with_args(snippet: &str, args: &[&str]) -> Output { } cmd.output().expect("failed to spawn sh; is /bin/sh available?") } + +/// Parse every stderr line as one JSON record. +/// +/// # Panics +/// +/// Panics when any non-empty stderr line is not valid JSON. +#[doc(hidden)] +#[must_use] +pub fn stderr_json_records(output: &Output) -> Vec { + let stderr = String::from_utf8_lossy(&output.stderr); + stderr + .lines() + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_str(line).unwrap_or_else(|error| panic!("stderr line is not JSON: {line}; error: {error}"))) + .collect() +} + +/// Return the single JSON record emitted on stderr. +/// +/// # Panics +/// +/// Panics when stderr does not contain exactly one JSON record. +#[doc(hidden)] +#[must_use] +pub fn single_stderr_json_record(output: &Output) -> Value { + let records = stderr_json_records(output); + assert_eq!( + records.len(), + 1, + "expected exactly one stderr JSON record; stderr={}", + String::from_utf8_lossy(&output.stderr), + ); + records.into_iter().next().expect("one record was asserted above") +} + +/// Assert the common entry-script JSON control-plane fields. +/// +/// # Panics +/// +/// Panics when the record does not match the entry-script contract or +/// the message does not contain `message_needle`. +#[doc(hidden)] +pub fn assert_entry_script_record(record: &Value, kind: &str, level: &str, message_needle: &str) { + assert_eq!(record.get("schema").and_then(Value::as_u64), Some(1)); + assert_eq!( + record.get("command").and_then(Value::as_str), + Some("torrust-index-entry-script"), + ); + assert_eq!(record.get("kind").and_then(Value::as_str), Some(kind)); + assert_eq!(record.pointer("/fields/type").and_then(Value::as_str), Some("entry_script"),); + assert_eq!(record.pointer("/fields/level").and_then(Value::as_str), Some(level)); + + let message = record + .get("message") + .and_then(Value::as_str) + .expect("record message must be a string"); + assert!( + message.contains(message_needle), + "expected message containing {message_needle:?}; got {message:?}", + ); +} diff --git a/packages/index-entry-script/tests/seed_sqlite.rs b/packages/index-entry-script/tests/seed_sqlite.rs index b4806103..4faf64a2 100644 --- a/packages/index-entry-script/tests/seed_sqlite.rs +++ b/packages/index-entry-script/tests/seed_sqlite.rs @@ -7,21 +7,21 @@ //! //! | Test | Outcome | //! |--------------------------------|----------------------------------| -//! | `empty_path_errors` | exit 1 with "database.path is empty" | -//! | `memory_path_skips` | exit 0 with INFO line | -//! | `relative_path_skips` | exit 0 with WARN line | +//! | `empty_path_errors` | exit 1 with JSON diagnostic | +//! | `memory_path_skips` | exit 0 with JSON info status | +//! | `relative_path_skips` | exit 0 with JSON warning status | //! | `nonempty_absolute_untouched` | exit 0, file bytes unchanged | -//! | `outside_volumes_errors` | exit 1 with "outside the … volumes" | +//! | `outside_volumes_errors` | exit 1 with JSON diagnostic | //! //! The "missing-under-volume seeded" outcome (mkdir + `inst()` //! into `/var/lib/torrust/index/`) requires root and the //! container's `torrust` user; it is exercised by the -//! container e2e suite (Phase 8/9). +//! container e2e suite. use std::fs; use tempfile::TempDir; -use torrust_index_entry_script::run_sh_with_args; +use torrust_index_entry_script::{assert_entry_script_record, run_sh_with_args, single_stderr_json_record}; const SNIPPET: &str = "seed_sqlite \"$1\""; @@ -29,10 +29,11 @@ const SNIPPET: &str = "seed_sqlite \"$1\""; fn empty_path_errors() { let out = run_sh_with_args(SNIPPET, &[""]); assert_eq!(out.status.code(), Some(1), "expected exit 1"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("database.path is empty"), - "missing diagnostic; stderr={stderr}", + let record = single_stderr_json_record(&out); + assert_entry_script_record(&record, "diagnostic", "error", "database.path is empty"); + assert_eq!( + record.pointer("/fields/exit_code").and_then(serde_json::Value::as_u64), + Some(1) ); } @@ -45,11 +46,8 @@ fn memory_path_skips() { out.status.code(), String::from_utf8_lossy(&out.stderr), ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("INFO") && stderr.contains(":memory:"), - "missing INFO/:memory: line; stderr={stderr}", - ); + let record = single_stderr_json_record(&out); + assert_entry_script_record(&record, "status", "info", ":memory:"); } #[test] @@ -61,11 +59,8 @@ fn relative_path_skips() { out.status.code(), String::from_utf8_lossy(&out.stderr), ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("WARN") && stderr.contains("relative SQLite path"), - "missing WARN/relative line; stderr={stderr}", - ); + let record = single_stderr_json_record(&out); + assert_entry_script_record(&record, "status", "warn", "relative SQLite path"); } #[test] @@ -101,9 +96,10 @@ fn outside_volumes_errors() { let out = run_sh_with_args(SNIPPET, &[path_str]); assert_eq!(out.status.code(), Some(1), "expected exit 1"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("outside the") && stderr.contains("volumes"), - "missing volumes-guard diagnostic; stderr={stderr}", + let record = single_stderr_json_record(&out); + assert_entry_script_record(&record, "diagnostic", "error", "outside the volumes"); + assert_eq!( + record.pointer("/fields/exit_code").and_then(serde_json::Value::as_u64), + Some(1) ); } diff --git a/packages/index-entry-script/tests/validate_auth_keys.rs b/packages/index-entry-script/tests/validate_auth_keys.rs index 2632fee2..63cd6598 100644 --- a/packages/index-entry-script/tests/validate_auth_keys.rs +++ b/packages/index-entry-script/tests/validate_auth_keys.rs @@ -9,11 +9,11 @@ //! | `both_none_passes` | happy path — no config | //! | `both_path_passes` | happy path — both PATH | //! | `both_pem_passes` | happy path — both PEM | -//! | `private_pem_and_path_errors` | per-key mutual exclusion | -//! | `public_pem_and_path_errors` | per-key mutual exclusion | -//! | `private_only_errors` | pair completeness | -//! | `public_only_errors` | pair completeness | -//! | `mixed_pem_path_errors` | cross-pair source consistency | +//! | `private_pem_and_path_errors` | JSON diagnostic for per-key mutual exclusion | +//! | `public_pem_and_path_errors` | JSON diagnostic for per-key mutual exclusion | +//! | `private_only_errors` | JSON diagnostic for pair completeness | +//! | `public_only_errors` | JSON diagnostic for pair completeness | +//! | `mixed_pem_path_errors` | JSON diagnostic for cross-pair source consistency | //! //! Argument order to `validate_auth_keys`: //! @@ -22,7 +22,7 @@ //! pub_pem_set pub_path_set pub_source //! ``` -use torrust_index_entry_script::run_sh_with_args; +use torrust_index_entry_script::{assert_entry_script_record, run_sh_with_args, single_stderr_json_record}; const SNIPPET: &str = "validate_auth_keys \"$@\""; @@ -53,10 +53,11 @@ fn assert_fail(args: &[&str], needle: &str) { out.status.code(), String::from_utf8_lossy(&out.stderr), ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains(needle), - "expected diagnostic containing {needle:?} for args {args:?}; got: {stderr}", + let record = single_stderr_json_record(&out); + assert_entry_script_record(&record, "diagnostic", "error", needle); + assert_eq!( + record.pointer("/fields/exit_code").and_then(serde_json::Value::as_u64), + Some(1) ); } diff --git a/share/container/entry_script_lib_sh b/share/container/entry_script_lib_sh index d7afe7e0..45e6ceac 100644 --- a/share/container/entry_script_lib_sh +++ b/share/container/entry_script_lib_sh @@ -9,12 +9,234 @@ # Conventions: # - All functions are POSIX sh; no bashisms. # - Functions use only `[ ... ]` and `case` for tests. -# - Functions emit human-readable diagnostics on stderr -# and use `exit 1` for unrecoverable input violations -# (auth-key validation, seed_sqlite path errors). When -# sourced for tests, callers must invoke each function -# in a subshell to observe the exit status without -# terminating the test driver. +# - Functions emit JSON diagnostics on stderr and use +# `exit 1` for unrecoverable input violations (auth-key +# validation, seed_sqlite path errors). When sourced for +# tests, callers must invoke each function in a subshell +# to observe the exit status without terminating the test +# driver. + +ENTRY_SCRIPT_JSON_SCHEMA=1 + +json_command_name() { + printf '%s' "${ENTRY_SCRIPT_COMMAND_NAME:-torrust-index-entry-script}" +} + +json_emit_failed_exit() { + ENTRY_SCRIPT_JSON_EXIT_HANDLED=1 + printf '%s\n' '{"schema":1,"command":"torrust-index-entry-script","kind":"diagnostic","message":"failed to emit JSON diagnostic","fields":{"type":"entry_script","level":"error","exit_code":1}}' >&2 + exit 1 +} + +json_require_jq() { + if command -v jq >/dev/null 2>&1; then + return 0 + fi + + ENTRY_SCRIPT_JSON_EXIT_HANDLED=1 + printf '%s\n' '{"schema":1,"command":"torrust-index-entry-script","kind":"diagnostic","message":"jq is required for container entry-script JSON diagnostics","fields":{"type":"entry_script","level":"error","exit_code":1}}' >&2 + exit 1 +} + +json_log() { + _json_kind=$1 + _json_level=$2 + _json_message=$3 + + json_require_jq + jq -cn \ + --arg command "$(json_command_name)" \ + --arg kind "$_json_kind" \ + --arg message "$_json_message" \ + --arg level "$_json_level" \ + '{schema:1, command:$command, kind:$kind, message:$message, fields:{type:"entry_script", level:$level}}' >&2 \ + || json_emit_failed_exit +} + +json_error_exit() { + _json_message=$1 + _json_exit_code=${2:-1} + + json_require_jq + jq -cn \ + --arg command "$(json_command_name)" \ + --arg message "$_json_message" \ + --argjson exit_code "$_json_exit_code" \ + '{schema:1, command:$command, kind:"diagnostic", message:$message, fields:{type:"entry_script", level:"error", exit_code:$exit_code}}' >&2 \ + || json_emit_failed_exit + + ENTRY_SCRIPT_JSON_EXIT_HANDLED=1 + exit "$_json_exit_code" +} + +json_external_log() { + _json_kind=$1 + _json_level=$2 + _json_message=$3 + _json_external_command=$4 + _json_external_stderr=$5 + + json_require_jq + jq -cn \ + --arg command "$(json_command_name)" \ + --arg kind "$_json_kind" \ + --arg level "$_json_level" \ + --arg message "$_json_message" \ + --arg external_command "$_json_external_command" \ + --arg external_stderr "$_json_external_stderr" \ + '{schema:1, command:$command, kind:$kind, message:$message, fields:{type:"external_command", level:$level, external_command:$external_command, external_stderr:$external_stderr}}' >&2 \ + || json_emit_failed_exit +} + +json_external_error_exit() { + _json_message=$1 + _json_exit_code=$2 + _json_external_command=$3 + _json_external_stderr=$4 + + json_require_jq + jq -cn \ + --arg command "$(json_command_name)" \ + --arg message "$_json_message" \ + --argjson exit_code "$_json_exit_code" \ + --arg external_command "$_json_external_command" \ + --arg external_stderr "$_json_external_stderr" \ + '{schema:1, command:$command, kind:"diagnostic", message:$message, fields:{type:"external_command", level:"error", exit_code:$exit_code, external_command:$external_command, external_stderr:$external_stderr}}' >&2 \ + || json_emit_failed_exit + + ENTRY_SCRIPT_JSON_EXIT_HANDLED=1 + exit "$_json_exit_code" +} + +json_unexpected_exit() { + _json_status=$? + + if [ "$_json_status" -eq 0 ]; then + return 0 + fi + if [ "${ENTRY_SCRIPT_JSON_EXIT_HANDLED:-0}" = "1" ]; then + exit "$_json_status" + fi + + ENTRY_SCRIPT_JSON_EXIT_HANDLED=1 + json_require_jq + jq -cn \ + --arg command "$(json_command_name)" \ + --arg phase "${entry_script_phase:-unknown}" \ + --argjson exit_code "$_json_status" \ + '{schema:1, command:$command, kind:"diagnostic", message:"unexpected entry script failure", fields:{type:"entry_script", level:"error", exit_code:$exit_code, phase:$phase}}' >&2 \ + || json_emit_failed_exit + exit "$_json_status" +} + +entry_script_set_phase() { + entry_script_phase=$1 + if [ "${DEBUG:-}" = "1" ]; then + json_log status debug "entry script phase: $entry_script_phase" + fi +} + +run_checked() { + _run_phase=$1 + shift + _run_command=$1 + _run_stderr="${TMPDIR:-/tmp}/torrust-entry-script.$$.$_run_command.stderr" + rm -f "$_run_stderr" + + if "$@" 2>"$_run_stderr"; then + _run_captured_stderr=$(cat "$_run_stderr" 2>/dev/null || true) + rm -f "$_run_stderr" + if [ -n "$_run_captured_stderr" ]; then + json_external_log status warn "$_run_phase wrote to stderr" "$_run_command" "$_run_captured_stderr" + fi + return 0 + fi + + _run_status=$? + _run_captured_stderr=$(cat "$_run_stderr" 2>/dev/null || true) + rm -f "$_run_stderr" + json_external_error_exit "$_run_phase failed" "$_run_status" "$_run_command" "$_run_captured_stderr" +} + +jq_read() { + _jq_filter=$1 + _jq_input=$2 + _jq_stderr="${TMPDIR:-/tmp}/torrust-entry-script.$$.jq.stderr" + rm -f "$_jq_stderr" + + if _jq_output=$(printf '%s' "$_jq_input" | jq -r "$_jq_filter" 2>"$_jq_stderr"); then + rm -f "$_jq_stderr" + printf '%s' "$_jq_output" + return 0 + fi + + _jq_status=$? + _jq_captured_stderr=$(cat "$_jq_stderr" 2>/dev/null || true) + rm -f "$_jq_stderr" + json_external_error_exit "jq failed while reading JSON input" "$_jq_status" jq "$_jq_captured_stderr" +} + +jq_write_file() { + _jq_filter=$1 + _jq_input=$2 + _jq_output_path=$3 + _jq_stderr="${TMPDIR:-/tmp}/torrust-entry-script.$$.jq.stderr" + rm -f "$_jq_stderr" + + if printf '%s' "$_jq_input" | jq -r "$_jq_filter" 2>"$_jq_stderr" >"$_jq_output_path"; then + rm -f "$_jq_stderr" + return 0 + fi + + _jq_status=$? + _jq_captured_stderr=$(cat "$_jq_stderr" 2>/dev/null || true) + rm -f "$_jq_stderr" + json_external_error_exit "jq failed while writing JSON field" "$_jq_status" jq "$_jq_captured_stderr" +} + +append_line_checked() { + _append_phase=$1 + _append_line=$2 + _append_path=$3 + _append_stderr="${TMPDIR:-/tmp}/torrust-entry-script.$$.append.stderr" + rm -f "$_append_stderr" + + if printf '%s\n' "$_append_line" 2>"$_append_stderr" >>"$_append_path"; then + _append_captured_stderr=$(cat "$_append_stderr" 2>/dev/null || true) + rm -f "$_append_stderr" + if [ -n "$_append_captured_stderr" ]; then + json_external_log status warn "$_append_phase wrote to stderr" shell "$_append_captured_stderr" + fi + return 0 + fi + + _append_status=$? + _append_captured_stderr=$(cat "$_append_stderr" 2>/dev/null || true) + rm -f "$_append_stderr" + json_external_error_exit "$_append_phase failed" "$_append_status" shell "$_append_captured_stderr" +} + +append_file_checked() { + _append_phase=$1 + _append_source=$2 + _append_path=$3 + _append_stderr="${TMPDIR:-/tmp}/torrust-entry-script.$$.append.stderr" + rm -f "$_append_stderr" + + if cat "$_append_source" 2>"$_append_stderr" >>"$_append_path"; then + _append_captured_stderr=$(cat "$_append_stderr" 2>/dev/null || true) + rm -f "$_append_stderr" + if [ -n "$_append_captured_stderr" ]; then + json_external_log status warn "$_append_phase wrote to stderr" cat "$_append_captured_stderr" + fi + return 0 + fi + + _append_status=$? + _append_captured_stderr=$(cat "$_append_stderr" 2>/dev/null || true) + rm -f "$_append_stderr" + json_external_error_exit "$_append_phase failed" "$_append_status" cat "$_append_captured_stderr" +} # install -D wrapper: copy $1 to $2 only if $1 exists and $2 # does not yet exist. Mode 0640, owner torrust:torrust. @@ -22,7 +244,7 @@ # POSIX §2.9.4.1. inst() { if [ -n "$1" ] && [ -n "$2" ] && [ -e "$1" ] && [ ! -e "$2" ]; then - install -D -m 0640 -o torrust -g torrust "$1" "$2"; fi; } + run_checked "install $2" install -D -m 0640 -o torrust -g torrust "$1" "$2"; fi; } # Returns 0 if $1 is one of the closed-set source values # {pem,path}; 1 otherwise. Tests positively against the @@ -59,31 +281,21 @@ validate_auth_keys() { _pub_pem_set=$4; _pub_path_set=$5; _pub_src=$6 if [ "$_priv_pem_set" = true ] && [ "$_priv_path_set" = true ]; then - echo "ERROR: both PRIVATE_KEY_PEM and PRIVATE_KEY_PATH are set;" \ - "these are mutually exclusive — pick one." >&2 - exit 1 + json_error_exit "both PRIVATE_KEY_PEM and PRIVATE_KEY_PATH are set; these are mutually exclusive - pick one." fi if [ "$_pub_pem_set" = true ] && [ "$_pub_path_set" = true ]; then - echo "ERROR: both PUBLIC_KEY_PEM and PUBLIC_KEY_PATH are set;" \ - "these are mutually exclusive — pick one." >&2 - exit 1 + json_error_exit "both PUBLIC_KEY_PEM and PUBLIC_KEY_PATH are set; these are mutually exclusive - pick one." fi _priv_has=0; key_configured "$_priv_src" && _priv_has=1 _pub_has=0; key_configured "$_pub_src" && _pub_has=1 if [ "$_priv_has" -ne "$_pub_has" ]; then - echo "ERROR: auth keys must be configured as a complete pair;" \ - "one key is configured but the other is not." >&2 - exit 1 + json_error_exit "auth keys must be configured as a complete pair; one key is configured but the other is not." fi if [ "$_priv_has" -eq 1 ] && [ "$_pub_has" -eq 1 ] \ && [ "$_priv_src" != "$_pub_src" ]; then - echo "ERROR: private key source is '$_priv_src'" \ - "but public key source is '$_pub_src';" \ - "mixed PEM/PATH across the key pair is not supported" \ - "— use the same delivery mechanism for both keys." >&2 - exit 1 + json_error_exit "private key source is '$_priv_src' but public key source is '$_pub_src'; mixed PEM/PATH across the key pair is not supported - use the same delivery mechanism for both keys." fi } @@ -100,22 +312,18 @@ seed_sqlite() { _template=/usr/share/torrust/default/database/index.sqlite3.db if [ -z "$_path" ]; then - echo "ERROR: probe reported sqlite driver but" \ - "database.path is empty — possible probe bug" >&2 - exit 1 + json_error_exit "probe reported sqlite driver but database.path is empty - possible probe bug" fi if [ "$_path" = ":memory:" ]; then - echo "INFO: SQLite :memory: — no database file to seed" >&2 + json_log status info "SQLite :memory: - no database file to seed" return 0 fi case $_path in /*) ;; *) - echo "WARN: relative SQLite path '$_path';" \ - "not seeding — application will create on" \ - "first connect if mode=rwc" >&2 + json_log status warn "relative SQLite path '$_path'; not seeding - application will create on first connect if mode=rwc" return 0 ;; esac @@ -125,7 +333,7 @@ seed_sqlite() { fi if [ -e "$_path" ] && [ ! -s "$_path" ]; then - rm -f "$_path" + run_checked "remove empty SQLite database file" rm -f "$_path" fi _dir=$(dirname "$_path") @@ -134,17 +342,12 @@ seed_sqlite() { /etc/torrust/index|/etc/torrust/index/*|\ /var/lib/torrust/index|/var/lib/torrust/index/*|\ /var/log/torrust/index|/var/log/torrust/index/*) - mkdir -p "$_dir" - chown torrust:torrust "$_dir" - chmod 0750 "$_dir" + run_checked "create SQLite database directory" mkdir -p "$_dir" + run_checked "set SQLite database directory owner" chown torrust:torrust "$_dir" + run_checked "set SQLite database directory mode" chmod 0750 "$_dir" ;; *) - echo "ERROR: database path $_dir is outside the" \ - "volumes the entry script manages." >&2 - echo " Pre-create it with appropriate" \ - "ownership, or use a path under" \ - "/var/lib/torrust/index/." >&2 - exit 1 + json_error_exit "database path $_dir is outside the volumes the entry script manages. Pre-create it with appropriate ownership, or use a path under /var/lib/torrust/index/." ;; esac fi diff --git a/share/container/entry_script_sh b/share/container/entry_script_sh index f4022560..9e9da162 100755 --- a/share/container/entry_script_sh +++ b/share/container/entry_script_sh @@ -1,5 +1,4 @@ #!/bin/sh -[ "${DEBUG:-}" = "1" ] && set -x # ── Canonical env-var manifest (ADR-T-009 Acceptance Criterion #7) ── # Single source of truth for every environment variable the @@ -44,17 +43,21 @@ set -eu # shellcheck disable=SC1091 # runtime path; not resolvable at lint time . /usr/local/lib/torrust/entry_script_lib_sh +json_require_jq +entry_script_phase=startup +trap 'json_unexpected_exit' EXIT +entry_script_set_phase startup + # ── User account setup ──────────────────────────────────── +entry_script_set_phase "user account setup" # D7: validate that USER_ID is numeric and is not 0 (root). case ${USER_ID:-} in ''|*[!0-9]*) - echo "ERROR: USER_ID is unset or not numeric" >&2 - exit 1 + json_error_exit "USER_ID is unset or not numeric" ;; esac if [ "$USER_ID" -eq 0 ]; then - echo "ERROR: USER_ID is 0 (root) — refusing to run as root" >&2 - exit 1 + json_error_exit "USER_ID is 0 (root) - refusing to run as root" fi # Use the busybox `adduser` short-option form so the same @@ -76,19 +79,21 @@ fi # writable layer, so the `torrust` group/user already exist # on subsequent boots. Both steps are guarded so the restart # path is a no-op rather than a fatal exit under `set -e`. -if ! grep -q '^torrust:' /etc/group; then - addgroup -g "$USER_ID" torrust +if ! grep -q '^torrust:' /etc/group 2>/dev/null; then + run_checked "create torrust group" addgroup -g "$USER_ID" torrust fi -if ! grep -q '^torrust:' /etc/passwd; then - adduser -D -s /bin/sh -u "$USER_ID" -G torrust torrust +if ! grep -q '^torrust:' /etc/passwd 2>/dev/null; then + run_checked "create torrust user" adduser -D -s /bin/sh -u "$USER_ID" -G torrust torrust fi # ── Volume directory ownership ──────────────────────────── -mkdir -p /var/lib/torrust/index/database/ /var/log/torrust/index/ /etc/torrust/index/ -chown -R "${USER_ID}" /var/lib/torrust /var/log/torrust /etc/torrust -chmod -R 2770 /var/lib/torrust /var/log/torrust /etc/torrust +entry_script_set_phase "volume directory ownership" +run_checked "create managed volume directories" mkdir -p /var/lib/torrust/index/database/ /var/log/torrust/index/ /etc/torrust/index/ +run_checked "set managed volume owner" chown -R "${USER_ID}" /var/lib/torrust /var/log/torrust /etc/torrust +run_checked "set managed volume mode" chmod -R 2770 /var/lib/torrust /var/log/torrust /etc/torrust # ── Default TOML installation ───────────────────────────── +entry_script_set_phase "default TOML installation" # Phase 5 made `database.connect_url` mandatory and Phase 9 # consequently consolidated the two driver-suffixed shipped # samples into a single driver-agnostic @@ -105,14 +110,10 @@ case ${TORRUST_INDEX_DATABASE_DRIVER:-} in default_config="/usr/share/torrust/default/config/index.container.toml" ;; '') - echo "ERROR: \$TORRUST_INDEX_DATABASE_DRIVER was not set!" >&2 - exit 1 + json_error_exit "TORRUST_INDEX_DATABASE_DRIVER was not set" ;; *) - echo "ERROR: unsupported database driver:" \ - "\"$TORRUST_INDEX_DATABASE_DRIVER\"" >&2 - echo " Supported values: sqlite3, mysql." >&2 - exit 1 + json_error_exit "unsupported database driver: '$TORRUST_INDEX_DATABASE_DRIVER'. Supported values: sqlite3, mysql." ;; esac @@ -120,43 +121,54 @@ install_config="/etc/torrust/index/index.toml" inst "$default_config" "$install_config" # ── Message of the day ──────────────────────────────────── +entry_script_set_phase "message of the day" case ${RUNTIME:-} in - runtime) printf '\n in runtime \n' >> /etc/motd ;; - debug) printf '\n in debug mode \n' >> /etc/motd ;; - release) printf '\n in release mode \n' >> /etc/motd ;; + runtime) + append_line_checked "append runtime motd spacer" "" /etc/motd + append_line_checked "append runtime motd marker" " in runtime " /etc/motd + ;; + debug) + append_line_checked "append debug motd spacer" "" /etc/motd + append_line_checked "append debug motd marker" " in debug mode " /etc/motd + ;; + release) + append_line_checked "append release motd spacer" "" /etc/motd + append_line_checked "append release motd marker" " in release mode " /etc/motd + ;; *) - echo "ERROR: running in unknown mode: \"${RUNTIME:-}\"" >&2 - exit 1 + json_error_exit "running in unknown mode: '${RUNTIME:-}'" ;; esac if [ -e "/usr/share/torrust/container/message" ]; then - cat "/usr/share/torrust/container/message" >> /etc/motd - chmod 0644 /etc/motd + append_file_checked "append container motd message" "/usr/share/torrust/container/message" /etc/motd + run_checked "set motd mode" chmod 0644 /etc/motd fi # Load message of the day from Profile # shellcheck disable=SC2016 -echo '[ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd' >> /etc/profile +append_line_checked "append motd profile hook" '[ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd' /etc/profile -cd /home/torrust || exit 1 +if ! cd /home/torrust 2>/dev/null; then + json_error_exit "failed to switch to /home/torrust" +fi # ── Config probe (ADR-T-009 §7.2) ───────────────────────── +entry_script_set_phase "config probe" # Resolve the operator's true configuration (TOML + env var # overrides) via the same loader the application uses. The # probe runs *before* this script exports any # TORRUST_INDEX_CONFIG_OVERRIDE_* of its own, so its output # reflects only operator-supplied values. The probe emits # one JSON object on stdout (P9 contract). -probe_json=$(/usr/bin/torrust-index-config-probe) +if ! probe_json=$(/usr/bin/torrust-index-config-probe); then + json_error_exit "config probe failed" +fi # Schema version gate — fail fast on probe/script mismatch. -probe_schema=$(printf '%s' "$probe_json" | jq -r '.schema') +probe_schema=$(jq_read '.schema' "$probe_json") if [ "$probe_schema" != "1" ]; then - echo "ERROR: config probe emitted schema=$probe_schema" \ - "but this entry script expects schema=1" \ - "— possible probe/script version mismatch" >&2 - exit 1 + json_error_exit "config probe emitted schema=$probe_schema but this entry script expects schema=1 - possible probe/script version mismatch" fi # jq field extraction. Each variable is assigned directly @@ -167,22 +179,23 @@ fi # also dereferenced via `eval` in the dispatch and # materialisation loops below (computed variable names of # the form `auth_${pair}_source`). -database_driver=$(printf '%s' "$probe_json" | jq -r '.database.driver') -database_path=$(printf '%s' "$probe_json" | jq -r '.database.path // empty') +database_driver=$(jq_read '.database.driver' "$probe_json") +database_path=$(jq_read '.database.path // empty' "$probe_json") -auth_private_key_pem_set=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.pem_set') -auth_private_key_path_set=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.path_set') -auth_private_key_source=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.source') +auth_private_key_pem_set=$(jq_read '.auth.private_key.pem_set' "$probe_json") +auth_private_key_path_set=$(jq_read '.auth.private_key.path_set' "$probe_json") +auth_private_key_source=$(jq_read '.auth.private_key.source' "$probe_json") # shellcheck disable=SC2034 # dereferenced via eval in auth-key loops -auth_private_key_path=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.path // empty') +auth_private_key_path=$(jq_read '.auth.private_key.path // empty' "$probe_json") -auth_public_key_pem_set=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.pem_set') -auth_public_key_path_set=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.path_set') -auth_public_key_source=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.source') +auth_public_key_pem_set=$(jq_read '.auth.public_key.pem_set' "$probe_json") +auth_public_key_path_set=$(jq_read '.auth.public_key.path_set' "$probe_json") +auth_public_key_source=$(jq_read '.auth.public_key.source' "$probe_json") # shellcheck disable=SC2034 # dereferenced via eval in auth-key loops -auth_public_key_path=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.path // empty') +auth_public_key_path=$(jq_read '.auth.public_key.path // empty' "$probe_json") # ── Auth-key validation (post-probe) ────────────────────── +entry_script_set_phase "auth key validation" # Three invariants enforced together (see validate_auth_keys # in entry_script_lib_sh): mutual exclusion within each key, # pair-completeness across both keys, and cross-pair source @@ -192,6 +205,7 @@ validate_auth_keys \ "$auth_public_key_pem_set" "$auth_public_key_path_set" "$auth_public_key_source" # ── Three-way auth-key path resolution (cases 1/2/3) ────── +entry_script_set_phase "auth key path resolution" # After this loop, ${pair}_path is set for every non-PEM key. for pair in private_key public_key; do src_var="auth_${pair}_source" @@ -225,6 +239,7 @@ for pair in private_key public_key; do done # ── Volumes-only directory guard for auth keys ──────────── +entry_script_set_phase "auth key directory guard" # Applies to cases 2 and 3. The script auto-creates parent # directories only inside the volumes it owns; anything # else is the operator's responsibility. @@ -242,38 +257,37 @@ for pair in private_key public_key; do /etc/torrust/index|/etc/torrust/index/*|\ /var/lib/torrust/index|/var/lib/torrust/index/*|\ /var/log/torrust/index|/var/log/torrust/index/*) - mkdir -p "$d" - chown torrust:torrust "$d" - chmod 0700 "$d" + run_checked "create auth key directory" mkdir -p "$d" + run_checked "set auth key directory owner" chown torrust:torrust "$d" + run_checked "set auth key directory mode" chmod 0700 "$d" ;; *) - echo "ERROR: auth key path $d is outside the volumes" \ - "the entry script manages." >&2 - echo " Pre-create it with appropriate ownership," \ - "or place keys under /etc/torrust/index/ or" \ - "/var/lib/torrust/index/." >&2 - exit 1 + json_error_exit "auth key path $d is outside the volumes the entry script manages. Pre-create it with appropriate ownership, or place keys under /etc/torrust/index/ or /var/lib/torrust/index/." ;; esac done # ── Key materialisation (cases 2 and 3) ─────────────────── +entry_script_set_phase "auth key materialisation" # Both keys are generated together as a pair (the generator # emits a matched keypair in one invocation). If either file # is missing or empty, regenerate both — a half-pair is not # useful. if [ -n "${private_key_path:-}" ] && [ -n "${public_key_path:-}" ]; then if [ ! -s "$private_key_path" ] || [ ! -s "$public_key_path" ]; then - keypair_json=$(/usr/bin/torrust-index-auth-keypair) - printf '%s' "$keypair_json" | jq -r .private_key_pem > "$private_key_path" - printf '%s' "$keypair_json" | jq -r .public_key_pem > "$public_key_path" - chown torrust:torrust "$private_key_path" "$public_key_path" - chmod 0400 "$private_key_path" - chmod 0400 "$public_key_path" + if ! keypair_json=$(/usr/bin/torrust-index-auth-keypair); then + json_error_exit "auth keypair generator failed" + fi + jq_write_file .private_key_pem "$keypair_json" "$private_key_path" + jq_write_file .public_key_pem "$keypair_json" "$public_key_path" + run_checked "set auth key owner" chown torrust:torrust "$private_key_path" "$public_key_path" + run_checked "set private auth key mode" chmod 0400 "$private_key_path" + run_checked "set public auth key mode" chmod 0400 "$public_key_path" fi fi # ── Database seeding dispatch (probe-driven) ────────────── +entry_script_set_phase "database seeding" # `database_driver` comes from the probe's # `.database.driver` field, derived from `connect_url`'s URL # scheme — not from `TORRUST_INDEX_DATABASE_DRIVER`. @@ -292,11 +306,13 @@ case $database_driver in # The probe rejects unknown schemes before reaching # this point, so this branch indicates a probe-vs- # script version mismatch. - echo "ERROR: unexpected database.driver='$database_driver'" \ - "from config probe — possible probe/script version mismatch" >&2 - exit 1 + json_error_exit "unexpected database.driver='$database_driver' from config probe - possible probe/script version mismatch" ;; esac # ── Drop privileges and exec the application ────────────── +entry_script_set_phase "exec application" +if [ ! -x /bin/su-exec ]; then + json_error_exit "/bin/su-exec is not executable" +fi exec /bin/su-exec torrust "$@" From d07fee7fb2e0e11b7505ad014e7bdd61eb9cc073 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 14:16:48 +0200 Subject: [PATCH 09/13] docs(cli): refresh ADR-T-010 command examples Update the root maintenance and torrent-helper command docs to show the current ADR-T-010 usage pattern: quiet cargo output, explicit argument separators, NDJSON stderr capture, and jq inspection where applicable. Refresh the v1.0.0-to-v2.0.0 upgrade guide and upgrader follow-up hint so operators get the same stderr-capture command for upgrade and tracker statistics import workflows. --- src/bin/create_test_torrent.rs | 9 +++++++++ src/bin/import_tracker_statistics.rs | 10 ++++++++-- src/bin/parse_torrent.rs | 9 +++++++++ src/bin/seeder.rs | 3 ++- src/bin/upgrade.rs | 10 ++++++++-- src/console/commands/seeder/app.rs | 12 ++++++++---- .../commands/tracker_statistics_importer/app.rs | 10 ++++++++-- src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs | 11 +++++++---- upgrades/from_v1_0_0_to_v2_0_0/README.md | 10 ++++++++-- 9 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/bin/create_test_torrent.rs b/src/bin/create_test_torrent.rs index 1790773a..41a03234 100644 --- a/src/bin/create_test_torrent.rs +++ b/src/bin/create_test_torrent.rs @@ -1,4 +1,13 @@ //! Command line tool to create a test torrent file. +//! +//! ADR-T-010 classifies this as a no-stdout side-effect command: stdout remains +//! empty, and status or diagnostic records are emitted as JSON/NDJSON on stderr. +//! Capture stderr when automation needs the generated path or failure details. +//! +//! ```text +//! cargo run --quiet --bin create_test_torrent -- ./output/test/torrents 2>create-test-torrent.ndjson +//! jq . create-test-torrent.ndjson +//! ``` use std::fs::File; use std::io::Write; diff --git a/src/bin/import_tracker_statistics.rs b/src/bin/import_tracker_statistics.rs index d4cdac48..68e00727 100644 --- a/src/bin/import_tracker_statistics.rs +++ b/src/bin/import_tracker_statistics.rs @@ -2,10 +2,16 @@ //! //! It imports the number of seeders and leechers for all torrents from the linked tracker. //! -//! You can execute it with: `cargo run --bin import_tracker_statistics`. +//! You can execute it with: +//! +//! ```text +//! cargo run --quiet --bin import_tracker_statistics -- 2>import-tracker-statistics.ndjson +//! jq . import-tracker-statistics.ndjson +//! ``` //! //! ADR-T-010 classifies this as a side-effect command: stdout remains empty and -//! diagnostics are JSON records on stderr. +//! diagnostics are JSON records on stderr. Scripts should branch on the process +//! exit code and parse stderr as NDJSON when they need diagnostics. use std::process::ExitCode; use clap::Parser; diff --git a/src/bin/parse_torrent.rs b/src/bin/parse_torrent.rs index 131c2ed4..936d43e8 100644 --- a/src/bin/parse_torrent.rs +++ b/src/bin/parse_torrent.rs @@ -1,4 +1,13 @@ //! Command line tool to parse a torrent file and emit the decoded torrent. +//! +//! ADR-T-010 classifies this as a stdout-result command: success emits one JSON +//! object with `schema`, `torrent`, `original_v1_info_hash`, and +//! `input_byte_length`; failures leave stdout empty and report JSON diagnostics +//! on stderr. Direct terminal stdout is refused, so pipe or redirect the result. +//! +//! ```text +//! cargo run --quiet --bin parse_torrent -- ./path/to/file.torrent | jq . +//! ``` use std::path::{Path, PathBuf}; use std::process::ExitCode; diff --git a/src/bin/seeder.rs b/src/bin/seeder.rs index 26846b0a..38806843 100644 --- a/src/bin/seeder.rs +++ b/src/bin/seeder.rs @@ -1,7 +1,8 @@ //! Program to upload random torrents to a live Index API. //! //! ADR-T-010 classifies this as a side-effect command: stdout remains empty and -//! diagnostics are JSON records on stderr. +//! diagnostics are JSON records on stderr. Scripts should branch on the process +//! exit code and parse stderr as NDJSON when they need diagnostics. use std::process::ExitCode; use torrust_index::console::commands::seeder::app::{self, Args}; diff --git a/src/bin/upgrade.rs b/src/bin/upgrade.rs index d579c52b..76a12740 100644 --- a/src/bin/upgrade.rs +++ b/src/bin/upgrade.rs @@ -1,9 +1,15 @@ //! Upgrade command. //! It updates the application from version v1.0.0 to v2.0.0. -//! You can execute it with: `cargo run --bin upgrade ./data.db ./data_v2.db ./uploads`. +//! You can execute it with: +//! +//! ```text +//! cargo run --quiet --bin upgrade -- ./data.db ./data_v2.db ./uploads 2>upgrade.ndjson +//! jq . upgrade.ndjson +//! ``` //! //! ADR-T-010 classifies this as a side-effect command: stdout remains empty and -//! diagnostics are JSON records on stderr. +//! diagnostics are JSON records on stderr. Scripts should branch on the process +//! exit code and parse stderr as NDJSON when they need diagnostics. use std::process::ExitCode; use clap::Parser; diff --git a/src/console/commands/seeder/app.rs b/src/console/commands/seeder/app.rs index 2289ac9a..87ab33df 100644 --- a/src/console/commands/seeder/app.rs +++ b/src/console/commands/seeder/app.rs @@ -6,23 +6,27 @@ //! Run with: //! //! ```text -//! cargo run --bin seeder -- \ +//! cargo run --quiet --bin seeder -- \ //! --api-base-url \ //! --number-of-torrents \ //! --user \ //! --password \ -//! --interval +//! --interval \ +//! 2>seeder.ndjson +//! jq . seeder.ndjson //! ``` //! //! For example: //! //! ```text -//! cargo run --bin seeder -- \ +//! cargo run --quiet --bin seeder -- \ //! --api-base-url "http://localhost:3001" \ //! --number-of-torrents 1000 \ //! --user admin \ //! --password 12345678 \ -//! --interval 0 +//! --interval 0 \ +//! 2>seeder.ndjson +//! jq . seeder.ndjson //! ``` //! //! That command would upload 1000 random torrents to the Index using the user diff --git a/src/console/commands/tracker_statistics_importer/app.rs b/src/console/commands/tracker_statistics_importer/app.rs index 88570eb8..7f782355 100644 --- a/src/console/commands/tracker_statistics_importer/app.rs +++ b/src/console/commands/tracker_statistics_importer/app.rs @@ -3,10 +3,16 @@ //! It imports the number of seeders and leechers for all torrents from the //! associated tracker. //! -//! You can execute it with: `cargo run --bin import_tracker_statistics`. +//! You can execute it with: +//! +//! ```text +//! cargo run --quiet --bin import_tracker_statistics -- 2>import-tracker-statistics.ndjson +//! jq . import-tracker-statistics.ndjson +//! ``` //! //! ADR-T-010 classifies this as a side-effect command: stdout remains empty and -//! diagnostics are JSON records on stderr. +//! diagnostics are JSON records on stderr. Scripts should branch on the process +//! exit code and parse stderr as NDJSON when they need diagnostics. //! //! Statistics are also imported: //! diff --git a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs index b3946f03..7fd1acf4 100644 --- a/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs +++ b/src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs @@ -3,7 +3,8 @@ //! # Usage //! //! ```bash -//! cargo run --bin upgrade SOURCE_DB_FILE TARGET_DB_FILE TORRENT_UPLOAD_DIR +//! cargo run --quiet --bin upgrade -- SOURCE_DB_FILE TARGET_DB_FILE TORRENT_UPLOAD_DIR 2>upgrade.ndjson +//! jq . upgrade.ndjson //! ``` //! //! Where: @@ -15,11 +16,13 @@ //! For example: //! //! ```bash -//! cargo run --bin upgrade ./data.db ./data_v2.db ./uploads +//! cargo run --quiet --bin upgrade -- ./data.db ./data_v2.db ./uploads 2>upgrade.ndjson +//! jq . upgrade.ndjson //! ``` //! //! ADR-T-010 classifies this as a side-effect command: stdout remains empty and -//! diagnostics are JSON records on stderr. +//! diagnostics are JSON records on stderr. Scripts should branch on the process +//! exit code and parse stderr as NDJSON when they need diagnostics. //! //! This command was created to help users to migrate from version `v1.0.0` to //! `v2.0.0`. The main changes in version `v2.0.0` were: @@ -108,7 +111,7 @@ pub async fn upgrade(args: &Arguments, date_imported: &str) -> Result<(), Upgrad info!("upgrade data from version v1.0.0 to v2.0.0 finished"); info!( - command = "cargo run --bin import_tracker_statistics", + command = "cargo run --quiet --bin import_tracker_statistics -- 2>import-tracker-statistics.ndjson", "run the tracker statistics importer manually if you want all torrent statistics imported before normal execution" ); diff --git a/upgrades/from_v1_0_0_to_v2_0_0/README.md b/upgrades/from_v1_0_0_to_v2_0_0/README.md index a90cb14d..bc4d4ce9 100644 --- a/upgrades/from_v1_0_0_to_v2_0_0/README.md +++ b/upgrades/from_v1_0_0_to_v2_0_0/README.md @@ -7,7 +7,13 @@ To upgrade from version `v1.0.0` to `v2.0.0` you have to follow these steps: - Back up your current database and the `uploads` folder. You can find which database and upload folder are you using in the `Config.toml` file in the root folder of your installation. - Set up a local environment exactly as you have it in production with your production data (DB and torrents folder). - Run the application locally with: `cargo run`. -- Execute the upgrader command: `cargo run --bin upgrade -- ./data.db ./data_v2.db ./uploads` +- Execute the upgrader command and capture its JSON stderr diagnostics: + +```sh +cargo run --quiet --bin upgrade -- ./data.db ./data_v2.db ./uploads 2>upgrade.ndjson +jq . upgrade.ndjson +``` + - A new SQLite file should have been created in the root folder: `data_v2.db` - Stop the running application and change the DB configuration to use the newly generated configuration: @@ -32,7 +38,7 @@ it needs diagnostics. Usage failures, including invalid argv, exit with code 2; runtime upgrade failures exit with code 1. ```sh -cargo run --bin upgrade -- ./data.db ./data_v2.db ./uploads 2>upgrade.ndjson +cargo run --quiet --bin upgrade -- ./data.db ./data_v2.db ./uploads 2>upgrade.ndjson jq . upgrade.ndjson ``` From c43099508fb11138fa881307a7bd9bc2dfad6e6d Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 14:55:44 +0200 Subject: [PATCH 10/13] test(cli): add ADR-T-010 regression guards Deny raw Rust stdout/stderr print macros and direct process exits at the workspace Clippy boundary, with local exceptions for build-script protocol output, developer examples, test diagnostics, and the shared CLI exit helper. Add a cli_contract integration test that keeps in-scope binary main functions returning ExitCode and rejects Result-based termination so future changes do not reintroduce raw stderr output. Update the ADR-T-010 rollout plan and changelog to record the stage-ten conformance guards. --- CHANGELOG.md | 8 +- Cargo.toml | 3 + build.rs | 2 + ...10-command-line-output-conformance-plan.md | 59 +++++++++----- packages/index-cli-common/src/lib.rs | 1 + packages/index-health-check/src/tests/mod.rs | 2 + packages/mudlark/src/testing/runner.rs | 2 + packages/mudlark/src/tests/decompose_basis.rs | 2 + .../src/tests/semi_internal_plateau.rs | 2 + packages/mudlark/tests/eviction_plateau.rs | 1 + packages/mudlark/tests/negative_f64.rs | 2 + packages/mudlark/tests/pedagogy.rs | 2 + packages/mudlark/tests/pedagogy_advanced.rs | 2 + .../examples/test_render.rs | 2 + tests/cli_contract.rs | 80 +++++++++++++++++++ tests/common/contexts/torrent/fixtures.rs | 2 + .../web/api/v1/contexts/torrent/contract.rs | 2 + .../e2e/web/api/v1/contexts/torrent/steps.rs | 2 + .../from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs | 2 +- 19 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 tests/cli_contract.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e3094f8f..f58f1f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,11 @@ error system (ADR-T-006), MSRV raised to 1.88. `--debug` precedence, a non-interleaving stderr writer, and stdout/no-stdout command runners, including an async runner for no-stdout side-effect commands. +- Stage-10 regression guards for ADR-T-010: workspace Clippy lint levels now + deny raw Rust stdout/stderr print macros and direct `std::process::exit` + outside explicit local exceptions, and the `cli_contract` integration test + keeps in-scope binary `main` functions returning `ExitCode` instead of + `Result`. #### Changed @@ -139,7 +144,8 @@ error system (ADR-T-006), MSRV raised to 1.88. binaries have the JSON stdout contract, the server emits JSON tracing on stderr, root Rust command migrations and shared-library cleanup have their JSON stream contracts, and the container entry script has its stage-eight JSON - stderr contract documented. + stderr contract documented. The conformance plan also records the stage-ten + regression guard implementation. ### ADR-T-009 — Container infrastructure refactor diff --git a/Cargo.toml b/Cargo.toml index 343e0450..832d9d9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,9 +138,12 @@ warnings = { level = "deny", priority = -1 } all = { level = "deny", priority = -1 } complexity = { level = "deny", priority = -1 } correctness = { level = "deny", priority = -1 } +exit = { level = "deny", priority = 0 } nursery = { level = "warn", priority = -1 } pedantic = { level = "deny", priority = -1 } perf = { level = "deny", priority = -1 } +print_stderr = { level = "deny", priority = 0 } +print_stdout = { level = "deny", priority = 0 } style = { level = "deny", priority = -1 } suspicious = { level = "deny", priority = -1 } diff --git a/build.rs b/build.rs index d5068697..ec3a95d9 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,5 @@ +#![allow(clippy::print_stdout)] + // generated by `sqlx migrate build-script` fn main() { // trigger recompilation when a new migration is added diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md index bd9ed6ed..5a295b3b 100644 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ b/docs/plans/adr-010-command-line-output-conformance-plan.md @@ -11,8 +11,9 @@ code, documentation, and regression tests. ## Current Implementation Status -Stages 1 through 8 have landed for the shared Rust helper path, root command -migrations, command-reachable shared-library cleanup, and container entry script: +Stages 1 through 10 have landed for the shared Rust helper path, root command +migrations, command-reachable shared-library cleanup, container entry script, +documentation, and regression guards: - Stage 1 fixed the shared control-plane record shape, baseline exit classes, helper stdout schemas, and redaction helpers. @@ -48,9 +49,13 @@ migrations, command-reachable shared-library cleanup, and container entry script explicit JSON phase records instead of `set -x`, expected validation failures are reported as JSON diagnostics, and utility failures controlled by the script are captured and re-emitted as JSON fields. - -Documentation updates and regression guards remain future rollout stages unless -their sections below say otherwise. +- Stage 9 updated the operator documentation and changelog entries for the + landed command-output migrations. +- Stage 10 added workspace Clippy guards for raw Rust stream output and direct + process exits, explicit out-of-scope test/build/example exceptions, and a + binary-boundary regression test that keeps in-scope `main` functions returning + `ExitCode` instead of `Result`. The Clippy guard is configured through + workspace lint levels in `Cargo.toml`; no separate `clippy.toml` is used. ## Goal @@ -497,9 +502,10 @@ stage-four server logging / root `ExitCode` boundary state, the stage-five `parse_torrent` / `create_test_torrent` migration, the stage-six root maintenance command migration, the stage-seven command-reachable shared-library cleanup, and the stage-eight container entry-script migration have been -documented. Later documentation work should focus on regression guards or future -command-specific contracts rather than re-describing the stage-eight rollout as -pending. +documented. The stage-nine documentation pass and stage-ten regression guard +implementation have also been documented. Later documentation work should focus +on future command-specific contracts rather than re-describing completed rollout +stages as pending. Documentation maintenance requirements: @@ -520,6 +526,15 @@ Documentation maintenance requirements: Add focused conformance tests near the command code and one broad guard to catch future regressions. +Stage 10 status: implemented for the broad guard layer. Workspace Clippy now +denies raw Rust stdout/stderr print macros and direct `std::process::exit` +outside explicit exceptions; test-only, build-script protocol, developer-example, +and shared CLI infrastructure exceptions are annotated locally. The guard is +configured with workspace lint levels in `Cargo.toml`; no separate Clippy +configuration file is used. The root `cli_contract` integration test scans all +in-scope binaries and fails if a `main` boundary stops returning `ExitCode` or +regresses to `Result` termination. + Required tests: - `packages/index-cli-common` tests for JSON help records, JSON version records, @@ -537,17 +552,17 @@ Required tests: and for no-stdout commands keeping stdout empty while logging JSON stderr. - Container entry-script tests in `packages/index-entry-script` for JSON stderr on each validation failure branch. -- Workspace clippy guards for raw stream output in shipped command paths. Prefer - `clippy::print_stdout`, `clippy::print_stderr`, and `clippy.toml` - `disallowed-macros` entries for `println!`, `eprintln!`, `print!`, and - `eprint!`, with explicit allow-list entries or local `#[allow]` annotations - for Cargo build-script protocol output and tests. +- Workspace clippy guards for raw stream output in shipped command paths. The + implemented guard denies `clippy::print_stdout` and `clippy::print_stderr` + from workspace lint levels in `Cargo.toml`, with local `#[allow]` annotations + for Cargo build-script protocol output, developer examples, and out-of-scope + test diagnostics. - A regression test for `main() -> Result` in in-scope binaries, because Rust's default `Result` termination writes raw text on failure. - A lint-backed guard for `std::process::exit` outside shared CLI infrastructure - and shell entry scripts. Prefer `clippy::exit` or a `clippy.toml` - `disallowed-methods` entry when supported, with explicit allow-list entries - for the shared CLI infrastructure and shell entry scripts. + and shell entry scripts. The implemented guard denies `clippy::exit` from + workspace lint levels in `Cargo.toml`, with a local exception for the shared + CLI infrastructure's `exit_with` helper. - TTY-refusal smoke tests for stdout-producing commands. Use a pseudo-terminal library or tool such as `rexpect` or `portable-pty` if in-process Rust tests cannot reliably allocate a TTY. @@ -571,13 +586,13 @@ summarizing results, following the repository test-running convention. ## Rollout Order -Current status: steps 1 through 9 have landed. Documentation and changelog +Current status: steps 1 through 10 have landed. Documentation and changelog entries for the shared-helper stages, the stage-four root logging / -binary-boundary rollout, the stage-five root binary migration, and the stage-six -root maintenance command migration, and the stage-seven shared-library cleanup -have been updated. The stage-eight container entry-script documentation and -changelog entries have also been updated. Later operator-visible migrations -still need their own documentation and changelog updates when they land. +binary-boundary rollout, the stage-five root binary migration, the stage-six +root maintenance command migration, the stage-seven shared-library cleanup, the +stage-eight container entry-script migration, and the stage-ten regression +guards have been updated. Later operator-visible migrations still need their own +documentation and changelog updates when they land. 1. Finalize the shared control-plane record shape, command-specific result schema details, exit-code mapping, and redaction rules. diff --git a/packages/index-cli-common/src/lib.rs b/packages/index-cli-common/src/lib.rs index c8744e50..e99f0de3 100644 --- a/packages/index-cli-common/src/lib.rs +++ b/packages/index-cli-common/src/lib.rs @@ -540,6 +540,7 @@ pub fn install_json_panic_hook(command_name: &str) { } /// Exit the current process with an ADR-T-010 exit class. +#[allow(clippy::exit)] pub fn exit_with(exit: CommandExit) -> ! { std::process::exit(i32::from(exit.code())); } diff --git a/packages/index-health-check/src/tests/mod.rs b/packages/index-health-check/src/tests/mod.rs index 0e72f412..939d50f6 100644 --- a/packages/index-health-check/src/tests/mod.rs +++ b/packages/index-health-check/src/tests/mod.rs @@ -1,3 +1,5 @@ +#![allow(clippy::print_stderr)] + //! # Health-check tests //! //! | Test | What it covers | diff --git a/packages/mudlark/src/testing/runner.rs b/packages/mudlark/src/testing/runner.rs index f83808b0..caac1e61 100644 --- a/packages/mudlark/src/testing/runner.rs +++ b/packages/mudlark/src/testing/runner.rs @@ -7,6 +7,8 @@ // checking invariants or budget constraints at a configurable // interval. +#![allow(clippy::print_stderr)] + use std::fmt::Display; use super::plan::Plan; diff --git a/packages/mudlark/src/tests/decompose_basis.rs b/packages/mudlark/src/tests/decompose_basis.rs index cc5a1c54..d2fc2d95 100644 --- a/packages/mudlark/src/tests/decompose_basis.rs +++ b/packages/mudlark/src/tests/decompose_basis.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: 2026 Torrust project contributors +#![allow(clippy::print_stderr)] + //! Boundary tests for **basis decomposition** via `contour_range()` //! (ADR-M-039). //! diff --git a/packages/mudlark/src/tests/semi_internal_plateau.rs b/packages/mudlark/src/tests/semi_internal_plateau.rs index 4a935df1..ba6a1e27 100644 --- a/packages/mudlark/src/tests/semi_internal_plateau.rs +++ b/packages/mudlark/src/tests/semi_internal_plateau.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: 2026 Torrust project contributors +#![allow(clippy::print_stderr)] + //! Unit tests for degenerate `SemiInternal` plateau shapes. //! //! These tests manually construct G-tree topologies with specific diff --git a/packages/mudlark/tests/eviction_plateau.rs b/packages/mudlark/tests/eviction_plateau.rs index 06a20eba..ba6cf94b 100644 --- a/packages/mudlark/tests/eviction_plateau.rs +++ b/packages/mudlark/tests/eviction_plateau.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Torrust project contributors #![cfg(feature = "dynamic-contour-tracking")] +#![allow(clippy::print_stderr)] //! Integration tests for eviction ↔ plateau invariant maintenance. //! //! These tests exercise the plateau invariants (P-I2 basis diff --git a/packages/mudlark/tests/negative_f64.rs b/packages/mudlark/tests/negative_f64.rs index a8c8aa16..a4e4163c 100644 --- a/packages/mudlark/tests/negative_f64.rs +++ b/packages/mudlark/tests/negative_f64.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: 2026 Torrust project contributors +#![allow(clippy::print_stderr)] + //! Integration tests for negative `f64` observations. //! //! `f64` implements `Accumulator` with `zero() = 0.0`, which diff --git a/packages/mudlark/tests/pedagogy.rs b/packages/mudlark/tests/pedagogy.rs index eb5a6a5d..592687ef 100644 --- a/packages/mudlark/tests/pedagogy.rs +++ b/packages/mudlark/tests/pedagogy.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: 2026 Torrust project contributors +#![allow(clippy::print_stdout)] + //! # End-to-End Pedagogy Test for the Worked Example //! //! This test walks through the construction, observation, rebalancing, diff --git a/packages/mudlark/tests/pedagogy_advanced.rs b/packages/mudlark/tests/pedagogy_advanced.rs index d9554b12..1449c993 100644 --- a/packages/mudlark/tests/pedagogy_advanced.rs +++ b/packages/mudlark/tests/pedagogy_advanced.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: 2026 Torrust project contributors +#![allow(clippy::print_stdout)] + //! # Advanced Pedagogy Test — Companion to [`pedagogy.rs`] //! //! The basic pedagogy test walks through the **mutation surface** of the diff --git a/packages/render-text-as-image/examples/test_render.rs b/packages/render-text-as-image/examples/test_render.rs index 7396b205..bad69891 100644 --- a/packages/render-text-as-image/examples/test_render.rs +++ b/packages/render-text-as-image/examples/test_render.rs @@ -1,3 +1,5 @@ +#![allow(clippy::print_stdout)] + use std::fs; use torrust_index_render_text_as_image::{RenderParams, Rgba, render_text_to_png}; diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs new file mode 100644 index 00000000..65feb28c --- /dev/null +++ b/tests/cli_contract.rs @@ -0,0 +1,80 @@ +//! # ADR-T-010 CLI contract regression tests +//! +//! | Test | What it covers | +//! |--------------------------------------------------|-----------------------------------------------------| +//! | `in_scope_binaries_return_exit_code_from_main` | In-scope binaries keep explicit `ExitCode` mains. | + +const IN_SCOPE_BINARIES: &[(&str, &str)] = &[ + ("src/main.rs", include_str!("../src/main.rs")), + ( + "src/bin/create_test_torrent.rs", + include_str!("../src/bin/create_test_torrent.rs"), + ), + ( + "src/bin/import_tracker_statistics.rs", + include_str!("../src/bin/import_tracker_statistics.rs"), + ), + ("src/bin/parse_torrent.rs", include_str!("../src/bin/parse_torrent.rs")), + ("src/bin/seeder.rs", include_str!("../src/bin/seeder.rs")), + ("src/bin/upgrade.rs", include_str!("../src/bin/upgrade.rs")), + ( + "packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs", + include_str!("../packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs"), + ), + ( + "packages/index-config-probe/src/bin/torrust-index-config-probe.rs", + include_str!("../packages/index-config-probe/src/bin/torrust-index-config-probe.rs"), + ), + ( + "packages/index-health-check/src/bin/torrust-index-health-check.rs", + include_str!("../packages/index-health-check/src/bin/torrust-index-health-check.rs"), + ), +]; + +#[test] +fn in_scope_binaries_return_exit_code_from_main() { + let mut failures = Vec::new(); + + for &(relative_path, source) in IN_SCOPE_BINARIES { + let Some(signature) = main_signature(source) else { + failures.push(format!("{relative_path}: missing main function")); + continue; + }; + + if !signature.contains("-> ExitCode") { + failures.push(format!( + "{relative_path}: main must return ExitCode instead of using Rust's default termination: `{signature}`" + )); + } + + if signature.contains("-> Result") { + failures.push(format!( + "{relative_path}: main must not return Result because default termination writes raw stderr: `{signature}`" + )); + } + } + + let message = failures.join("\n"); + assert!(failures.is_empty(), "{message}"); +} + +fn main_signature(source: &str) -> Option { + let start = source.find("fn main(")?; + let rest = &source[start..]; + let end = rest.find('{').unwrap_or(rest.len()); + + Some(collapse_whitespace(&rest[..end])) +} + +fn collapse_whitespace(value: &str) -> String { + let mut collapsed = String::new(); + + for part in value.split_whitespace() { + if !collapsed.is_empty() { + collapsed.push(' '); + } + collapsed.push_str(part); + } + + collapsed +} diff --git a/tests/common/contexts/torrent/fixtures.rs b/tests/common/contexts/torrent/fixtures.rs index aac5ccf7..3924d736 100644 --- a/tests/common/contexts/torrent/fixtures.rs +++ b/tests/common/contexts/torrent/fixtures.rs @@ -1,3 +1,5 @@ +#![allow(clippy::print_stderr, clippy::print_stdout)] + use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; diff --git a/tests/e2e/web/api/v1/contexts/torrent/contract.rs b/tests/e2e/web/api/v1/contexts/torrent/contract.rs index 1bf7ace9..70d84831 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/contract.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/contract.rs @@ -1,3 +1,5 @@ +#![allow(clippy::print_stdout)] + //! API contract for `torrent` context. //! //! # Test modules diff --git a/tests/e2e/web/api/v1/contexts/torrent/steps.rs b/tests/e2e/web/api/v1/contexts/torrent/steps.rs index 3f356481..9301ab67 100644 --- a/tests/e2e/web/api/v1/contexts/torrent/steps.rs +++ b/tests/e2e/web/api/v1/contexts/torrent/steps.rs @@ -1,3 +1,5 @@ +#![allow(clippy::print_stdout)] + use std::str::FromStr; use bittorrent_primitives::info_hash::InfoHash; diff --git a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs index 9d13635c..36f91b00 100644 --- a/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs +++ b/tests/upgrades/from_v1_0_0_to_v2_0_0/sqlite_v1_0_0.rs @@ -1,4 +1,4 @@ -#![allow(clippy::missing_errors_doc)] +#![allow(clippy::missing_errors_doc, clippy::print_stdout)] use std::fs; From c68a62678d7fd544e70e42273710e7fe246b63e7 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 15:19:03 +0200 Subject: [PATCH 11/13] docs(cli): consolidate ADR-T-010 implementation record Promote ADR-T-010 into the canonical implemented command-line output contract. Fold in the rollout plan's scope, command classifications, shared CLI infrastructure summary, implementation status, and regression guards so the ADR itself records the completed migration. Refresh the changelog from stage-by-stage rollout notes into a completed contract summary, and remove the now-obsolete standalone conformance plan. Update ADR-T-009 to describe the helper and config-probe work as prerequisites for the future entry-script rewrite. --- CHANGELOG.md | 104 ++- adr/009-container-infrastructure-refactor.md | 2 +- ...010-global-command-line-output-contract.md | 444 +++++++++++-- ...10-command-line-output-conformance-plan.md | 616 ------------------ 4 files changed, 445 insertions(+), 721 deletions(-) delete mode 100644 docs/plans/adr-010-command-line-output-conformance-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f58f1f45..11bdfb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,73 +79,51 @@ error system (ADR-T-006), MSRV raised to 1.88. #### Added -- ADR-T-010, extracting ADR-T-009's helper stdout/stderr convention into - a global command-line output contract for the application. -- Shared stage-1 command-line contract primitives in - `torrust-index-cli-common`: control-plane record schema, baseline exit-code - classes, structured help/version/usage/TTY-refusal/panic record types, and - diagnostic redaction helpers. -- Stage-2 shared CLI infrastructure in `torrust-index-cli-common`: JSON - `clap` help/version/usage wrapping, direct JSON stderr control-plane writes, - a JSON-only panic hook, idempotent JSON stderr tracing with `RUST_LOG` / - `--debug` precedence, a non-interleaving stderr writer, and stdout/no-stdout - command runners, including an async runner for no-stdout side-effect - commands. -- Stage-10 regression guards for ADR-T-010: workspace Clippy lint levels now - deny raw Rust stdout/stderr print macros and direct `std::process::exit` - outside explicit local exceptions, and the `cli_contract` integration test - keeps in-scope binary `main` functions returning `ExitCode` instead of - `Result`. +- ADR-T-010 establishes a repository-wide JSON-only output contract for + first-party command-line entrypoints. Stdout is reserved for result data; + stderr carries diagnostics and control records; commands that emit stdout + result data refuse direct terminal stdout. +- `torrust-index-cli-common` provides the shared implementation for that + contract: JSON `clap` help/version/usage handling, JSON panic diagnostics, + JSON stderr tracing, TTY refusal, stdout JSON emission, command runners, + baseline exit-code classes, control-plane record types, and redaction + helpers. +- Regression coverage now protects the contract with CLI behavior tests, + binary-boundary checks, and workspace lint rules denying accidental raw + stream output or direct process exits outside the shared CLI boundary. #### Changed -- Helper stdout result schemas are explicitly versioned with top-level - `schema` fields. `torrust-index-auth-keypair` emits `schema`, - `private_key_pem`, and `public_key_pem`; `torrust-index-config-probe` emits - `schema`, `database`, and `auth`; `torrust-index-health-check` emits - `schema`, `target`, `status`, and `elapsed_ms`. -- `torrust-index-auth-keypair`, `torrust-index-config-probe`, and - `torrust-index-health-check` now use the shared JSON `clap` parser and JSON - panic hook. Their `--help`, `--version`, argv errors, TTY refusal, and panic - diagnostics are JSON control-plane records on stderr; stdout remains reserved - for successful result JSON. -- Central application logging now delegates to the shared ADR-T-010 JSON stderr - tracing setup. The `torrust-index` server keeps stdout empty for normal - operation, uses the configured logging threshold as its default filter, and - lets a non-empty `RUST_LOG` override that default. -- Root Rust binaries now return explicit `ExitCode` values at their `main` - boundaries and install the shared JSON panic hook. `parse_torrent` and - `create_test_torrent` now use the shared JSON `clap` parser, JSON stderr - tracing runners, and focused CLI contract tests. -- `parse_torrent` now emits one JSON stdout result object with `schema`, - `torrent`, `original_v1_info_hash`, and `input_byte_length`, leaves stdout - empty on failure, and refuses direct terminal stdout with a JSON stderr - control-plane record. -- `create_test_torrent` now keeps stdout empty, reports the generated torrent - path as a JSON status record on stderr, and converts argument, encode, file - creation, and write failures into JSON diagnostics with explicit exit codes. -- `import_tracker_statistics`, `seeder`, and `upgrade` now use the shared JSON - `clap` parser, JSON panic hook, JSON stderr tracing runner, and no-stdout - side-effect command contract. Their command-reachable tracker statistics, - seeder, and upgrade paths now emit structured tracing diagnostics and - propagate command failures instead of printing plain text or relying on panic - output. -- Command-reachable shared libraries no longer emit raw stream output for - shutdown and mail-template diagnostics. Server shutdown notices now go through - structured tracing, and mail template initialization failures are propagated - to callers instead of printing or exiting from the mailer library. -- The container entry script now follows ADR-T-010 during its pre-`su-exec` - orchestration phase. It keeps stdout empty except for helper stdout captured - internally in command substitutions, emits JSON control-plane records on - stderr, checks for `jq` before JSON-dependent helpers run, emits `DEBUG=1` - phase records instead of enabling `set -x`, and wraps controlled utility +- Helper binaries (`torrust-index-auth-keypair`, + `torrust-index-config-probe`, and `torrust-index-health-check`) share the + JSON CLI boundary. Their successful stdout payloads remain single JSON + objects, now explicitly versioned with a top-level `schema` field, while + help, version, argv errors, TTY refusal, panic diagnostics, and tracing are + emitted as JSON records on stderr. +- The `torrust-index` server and root Rust binaries return explicit + `ExitCode` values at their `main` boundaries and install the shared JSON + panic hook. Central application logging uses JSON tracing on stderr, with a + non-empty `RUST_LOG` taking precedence over the configured default filter. +- `parse_torrent` is a stdout-result command. It emits one JSON object with + `schema`, `torrent`, `original_v1_info_hash`, and `input_byte_length`, leaves + stdout empty on failure, and refuses direct terminal stdout with a JSON + diagnostic record. +- `create_test_torrent`, `import_tracker_statistics`, `seeder`, and `upgrade` + are no-stdout side-effect commands. They keep stdout empty, report status and + diagnostics as JSON/NDJSON on stderr, and propagate command failures instead + of printing plain text or relying on panic output. +- Command-reachable shared libraries use the command diagnostic path instead of + raw stream output. Shutdown notices are structured tracing records, mail + template failures are returned to callers, terminal color formatting is + removed from command paths, and parsing helpers leave reporting decisions to + their command callers. +- The container entry script follows the JSON stderr contract during startup: + it captures helper stdout internally, keeps its own stdout empty before + `su-exec`, checks for `jq` before JSON-dependent helpers run, emits explicit + `DEBUG=1` phase records instead of `set -x`, and wraps controlled utility failures with captured stderr fields. -- Operator documentation now describes the ADR-T-010 migration state: helper - binaries have the JSON stdout contract, the server emits JSON tracing on - stderr, root Rust command migrations and shared-library cleanup have their - JSON stream contracts, and the container entry script has its stage-eight JSON - stderr contract documented. The conformance plan also records the stage-ten - regression guard implementation. +- Operator documentation and command examples describe the completed contract + across the README, container guide, upgrade notes, and command module docs. ### ADR-T-009 — Container infrastructure refactor diff --git a/adr/009-container-infrastructure-refactor.md b/adr/009-container-infrastructure-refactor.md index 3a3d0de0..3d76564c 100644 --- a/adr/009-container-infrastructure-refactor.md +++ b/adr/009-container-infrastructure-refactor.md @@ -1266,7 +1266,7 @@ Tracked for visibility; not part of this refactor: - `docker buildx` multi-platform builds (`linux/arm64`). - Image signing with `cosign`. - Pin base images (`gcr.io/distroless/cc-debian13` and `:debug`) by digest rather than tag for reproducible builds and supply-chain integrity. -- Reimplement the entry script's first-boot work as a small Rust binary (`torrust-index-entry`), eliminating vendored `su-exec` (privilege drop via direct `setgroups`/`setgid`/`setuid` syscalls), the shell-based IFS/heredoc parsing of probe output, and most of the curated busybox applet set. The `torrust-index-config` extraction, the ADR-T-010 helper conventions, and the `torrust-index-config-probe` helper are deliberate stepping stones: they pull the parsing surface out of the root crate, establish the stderr-tracing / stdout-JSON contract all helpers share, and prove the script-↔-Rust integration shape before committing to the full rewrite. The entry binary would depend on `torrust-index-config` and `torrust-index-auth-keypair` directly, eliminating the serialisation boundary entirely. +- Reimplement the entry script's first-boot work as a small Rust binary (`torrust-index-entry`), eliminating vendored `su-exec` (privilege drop via direct `setgroups`/`setgid`/`setuid` syscalls), the shell-based IFS/heredoc parsing of probe output, and most of the curated busybox applet set. The `torrust-index-config` extraction, the ADR-T-010 helper conventions, and the `torrust-index-config-probe` helper are deliberate prerequisites: they pull the parsing surface out of the root crate, establish the stderr-tracing / stdout-JSON contract all helpers share, and prove the script-↔-Rust integration shape before committing to the full rewrite. The entry binary would depend on `torrust-index-config` and `torrust-index-auth-keypair` directly, eliminating the serialisation boundary entirely. - Promote `packages/render-text-as-image/` to a published crate and drop the root crate's `path = "packages/..."` override; once that lands, the directory can safely be added to `.containerignore`. --- diff --git a/adr/010-global-command-line-output-contract.md b/adr/010-global-command-line-output-contract.md index 6c209cf9..fcc4de6c 100644 --- a/adr/010-global-command-line-output-contract.md +++ b/adr/010-global-command-line-output-contract.md @@ -1,92 +1,454 @@ # ADR-T-010: Global Command-Line Output Contract -**Status:** Decided -**Date:** 2026-05-13 +**Status:** Decided and implemented +**Date decided:** 2026-05-13 +**Date implemented:** 2026-05-13 **Supersedes:** The output-stream rules from ADR-T-009 P8/P9. **Relates to:** [ADR-T-009](009-container-infrastructure-refactor.md) (helper-binary extraction and dependency rules) -**Implementation plan:** [Command-Line Output Conformance Plan](../docs/plans/adr-010-command-line-output-conformance-plan.md) --- ## Context -ADR-T-009 introduced a strict stdout/stderr contract for container helper binaries: JSON results on stdout, JSON diagnostics on stderr, and no stdout result data directly to a terminal. That decision was made inside the container-infrastructure refactor because the entry script needed reliable JSON from small Rust helpers. +ADR-T-009 introduced a strict stdout/stderr contract for container helper +binaries: JSON results on stdout, JSON diagnostics on stderr, and no stdout +result data directly to a terminal. That decision was made inside the +container-infrastructure refactor because the entry script needed reliable JSON +from small Rust helpers. -The contract is not container-specific. The application has several first-party command-line entrypoints: the server binary, maintenance commands under `src/bin/`, container helpers under `packages/index-*/`, and future operator tools. If each command decides independently what stdout and stderr mean, shell integration becomes brittle and diagnostics can corrupt data streams. +The contract is not container-specific. The application has several first-party +command-line entrypoints: the server binary, maintenance commands under +`src/bin/`, container helpers under `packages/index-*/`, and future operator +tools. If each command decides independently what stdout and stderr mean, shell +integration becomes brittle and diagnostics can corrupt data streams. ## Decision -Adopt one repository-wide JSON-only command-line output contract for every first-party Torrust Index command-line entrypoint that is shipped, documented, or intended for operators. +Adopt one repository-wide JSON-only command-line output contract for every +first-party Torrust Index command-line entrypoint that is shipped, documented, +or intended for operators. -This includes: +--- + +## Scope + +### In scope + +Every shipped, documented, or operator-facing first-party command-line +entrypoint: + +- `src/main.rs` (`torrust-index` server binary). +- `src/bin/create_test_torrent.rs`. +- `src/bin/import_tracker_statistics.rs`. +- `src/bin/parse_torrent.rs`. +- `src/bin/seeder.rs`. +- `src/bin/upgrade.rs`. +- `packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs`. +- `packages/index-config-probe/src/bin/torrust-index-config-probe.rs`. +- `packages/index-health-check/src/bin/torrust-index-health-check.rs`. +- `share/container/entry_script_sh` and `share/container/entry_script_lib_sh`. + +Command-reachable library paths that emit operator-facing diagnostics: + +- `src/bootstrap/logging.rs`. +- `src/console/commands/seeder/app.rs`. +- `src/console/commands/seeder/logging.rs`. +- `src/console/cronjobs/tracker_statistics_importer.rs`. +- `src/mailer.rs`. +- `src/tracker/statistics_importer.rs`. +- `src/upgrades/from_v1_0_0_to_v2_0_0/`. +- `src/utils/parse_torrent.rs`. +- `src/web/api/server/signals.rs`. + +Future entry, migration, maintenance, diagnostic, or operator tools are +governed by this ADR from creation. + +### Out of scope + +Tests, examples, benches, one-off developer fixtures, and library packages with +no shipped binary entrypoint are outside the normative scope unless they are +documented as application commands: -- the main `torrust-index` server binary; -- binaries under `src/bin/`; -- helper binaries under `packages/index-*/`; -- future entry, migration, maintenance, diagnostic, or operator tools. +- `build.rs` Cargo protocol output such as `cargo:rerun-if-changed=...`. +- Tests, benches, examples, and harnesses under `tests/`, `src/tests/`, and + package test directories. +- Developer-only scripts under `contrib/dev-tools/`. +- Library packages with no shipped binary entrypoint, such as Mudlark and + `render-text-as-image`. -Tests, examples, benches, and one-off developer fixtures are outside the normative scope unless they are documented as application commands. +--- + +## Contract ### Streams -Stdout and stderr are both machine-readable streams. A command writes JSON records to them or leaves them empty. Plain human-readable text is not a valid application output format on either stream. +Stdout and stderr are both machine-readable streams. A command writes JSON +records to them or leaves them empty. Plain human-readable text is not a valid +application output format on either stream. + +Stdout is reserved for command result data intended for a caller to consume. +Diagnostics, logs, progress messages, warnings, help, usage, prompts, and +status updates go to stderr as JSON control-plane records. + +A command that has no stdout result data leaves stdout empty. A long-running +server process normally has no stdout result data; its diagnostics are logs and +therefore belong on stderr. + +### JSON output -Stdout is reserved for command result data intended for a caller to consume. Diagnostics, logs, progress messages, warnings, help, usage, prompts, and status updates go to stderr as JSON control-plane records. +When a command emits stdout result data, the default wire format is exactly one +JSON object followed by one trailing newline. -A command that has no stdout result data should leave stdout empty. A long-running server process normally has no stdout result data; its diagnostics are logs and therefore belong on stderr. +On success (exit 0), a command that has stdout result data emits its JSON object +on stdout and may emit JSON diagnostics on stderr. -### JSON Output +On failure (exit not 0), stdout is empty. The exit code is the branch signal for +callers, and JSON diagnostics go to stderr. -When a command emits stdout result data, the default wire format is exactly one JSON object followed by one trailing newline. +Commands that need a different stdout JSON shape, such as streaming output, must +document that exception in the command's own contract and explain why the +single-object JSON contract does not fit. When stdout result data is streaming, +stdout uses NDJSON: one JSON object per line. Non-JSON stdout or stderr is +outside this contract and requires a new ADR. -On success (exit 0), a command that has stdout result data emits its JSON object on stdout and may emit JSON diagnostics on stderr. +### TTY refusal -On failure (exit not 0), stdout is empty. The exit code is the branch signal for callers, and JSON diagnostics go to stderr. +A command that emits stdout result data refuses to run when stdout is attached +to a terminal. It exits before producing stdout and reports the diagnostic as +JSON on stderr. -Commands that need a different stdout JSON shape, such as streaming output, must document that exception in the command's own contract and explain why the single-object JSON contract does not fit. When stdout result data is streaming, stdout uses NDJSON: one JSON object per line. Non-JSON stdout or stderr is outside this contract and requires a new ADR. +The refusal is unconditional for commands with stdout result data. It does not +depend on whether the payload is sensitive, and it is not caused by the JSON +encoding. JSON is the only output encoding for both streams; the TTY refusal +exists because stdout result data is intended for another process or file. +Operators who want to inspect output interactively can pipe it to another +program such as `jq`, `less`, or `cat`. -### TTY Refusal +Commands that do not emit stdout result data do not refuse merely because stdout +is attached to a terminal. They leave stdout empty and write any diagnostics to +stderr as JSON. -A command that emits stdout result data refuses to run when stdout is attached to a terminal. It exits before producing stdout and reports the diagnostic as JSON on stderr. +### Exit codes -The refusal is unconditional for commands with stdout result data. It does not depend on whether the payload is sensitive, and it is not caused by the JSON encoding. JSON is the only output encoding for both streams; the TTY refusal exists because stdout result data is intended for another process or file. Operators who want to inspect output interactively can pipe it to another program such as `jq`, `less`, or `cat`. +The shared baseline exit-code classes are: -Commands that do not emit stdout result data do not refuse merely because stdout is attached to a terminal. They leave stdout empty and write any diagnostics to stderr as JSON. +| Class | Code | Meaning | +|---------|------|---------| +| success | 0 | Command succeeded. | +| failure | 1 | Runtime, startup, internal, or command execution failure. | +| usage | 2 | Command-line usage failure: clap argv errors, TTY refusal, or invalid arguments. | -Exit code 2 is reserved for command-line usage failures, including TTY refusal and argv parsing errors produced by `clap`. +Exit code 2 is reserved for command-line usage failures, including TTY refusal +and argv parsing errors produced by `clap`. + +Command-specific non-usage exit codes may still be documented by the owning +command contract. For example, `torrust-index-config-probe` keeps its existing +configuration and probe failure codes. ### Diagnostics -Operator-facing and script-facing commands use `tracing` for diagnostics. The diagnostic writer is stderr, configured with JSON output. +Operator-facing and script-facing commands use `tracing` for diagnostics. The +diagnostic writer is stderr, configured with JSON output. + +Stderr is a JSON control and diagnostic stream. When stderr emits multiple +records over time, it uses NDJSON: one complete JSON object per line. Diagnostic +records are `tracing` events so scripts can consume diagnostics without +scraping text. Non-diagnostic control records, such as help and usage, also +write JSON objects to stderr. Plain-text diagnostic formatting is not an output +mode for first-party application binaries; operators can pipe JSON diagnostics +to a viewer when they want a friendlier presentation. + +Command-specific diagnostics do not use `println!` or `eprintln!` for progress, +status, or errors; those would put raw text on stdout or stderr. + +### Help and usage output + +Help and usage information is command output and follows the same JSON-only +stream contract, but it is not stdout result data. -Stderr is a JSON control and diagnostic stream. When stderr emits multiple records over time, it uses NDJSON: one JSON object per line. Diagnostic records should be `tracing` events so scripts can consume diagnostics without scraping text. Non-diagnostic control records, such as help and usage, also write JSON objects to stderr. Plain-text diagnostic formatting is not an output mode for first-party application binaries; operators can pipe JSON diagnostics to a viewer when they want a friendlier presentation. +A help request writes a JSON control-plane record to stderr and exits with +code 0. It does not trigger stdout TTY refusal, because it does not emit stdout +result data. -### Help And Usage Output +A usage or argv-parse error writes a JSON diagnostic/control-plane record to +stderr and exits with code 2. -Help and usage information is command output and follows the same JSON-only stream contract, but it is not stdout result data. +Rust commands use `clap` for argv parsing. Raw `clap` help or error text is +wrapped in the JSON contract by the shared CLI infrastructure. -A help request writes a JSON control-plane record to stderr and exits with code 0. It does not trigger stdout TTY refusal, because it does not emit stdout result data. +--- + +## Shared Control-Plane Record Schema + +Shared stderr control-plane records use this top-level JSON shape: + +| Field | Type | Description | +|-----------|--------|-------------| +| `schema` | number | Shared control-plane record schema version. Initial value: `1`. | +| `command` | string | Binary or entrypoint name. | +| `kind` | string | One of `help`, `version`, `usage_error`, `tty_refusal`, `panic`, `status`, or `diagnostic`. | +| `message` | string | Short human-readable message carried inside the JSON record. | +| `fields` | object | Optional kind-specific object tagged with `type`. | + +### Structured field variants + +| Kind | Fields | +|---------------|--------| +| `help` | `text` | +| `version` | `version` | +| `usage_error` | `exit_code`, `clap_error_kind` | +| `tty_refusal` | `exit_code`, `stream` | +| `panic` | `exit_code`, `thread`, `location` | + +Panic payloads are not part of the shared record because they may contain +secrets. + +--- + +## Command Output Classification -A usage or argv-parse error writes a JSON diagnostic/control-plane record to stderr and exits with code 2. +### Commands with stdout result data -Rust commands may still use `clap` for argv parsing, but raw `clap` help or error text is a legacy gap unless it is wrapped in the JSON contract. +These commands emit one JSON object on stdout on success, refuse when stdout is +attached to a terminal, and leave stdout empty on failure: -Command-specific diagnostics should not use `println!` or `eprintln!` for progress, status, or errors; those would put raw text on stdout or stderr. +| Command | Stdout result schema | +|--------------------------------|----------------------| +| `torrust-index-auth-keypair` | `schema`, `private_key_pem`, `public_key_pem` | +| `torrust-index-config-probe` | `schema`, `database`, `auth` | +| `torrust-index-health-check` | `schema`, `target`, `status`, `elapsed_ms` | +| `parse_torrent` | `schema`, decoded torrent, original v1 info hash, input byte length | -## Implementation Guidance +All use the default single-object stdout contract. None use the documented +streaming NDJSON exception. -Use `torrust-index-cli-common` for Rust command-line tools. It provides the shared scaffolding for the global contract: JSON wrapping for `clap` help, version, and argv errors; JSON control-plane records on stderr before tracing is installed; TTY refusal for commands with stdout result data; JSON-only panic diagnostics; JSON tracing on stderr; JSON emission on stdout; the common `--debug` flag; and runners for stdout-producing and no-stdout command classes. +### Commands with no stdout result data -Tracing filter precedence is shared: a non-empty `RUST_LOG` environment variable wins, otherwise `--debug` selects debug-level diagnostics, otherwise the command's default level is used. +These commands leave stdout empty and write diagnostics to stderr as JSON: -Commands that do not emit stdout result data still follow the stream separation rule: diagnostics and logs go to stderr as JSON, preferably through `tracing`. +| Command | Description | +|--------------------------------|-------------| +| `torrust-index` | Long-running server; logs go to stderr. | +| `create_test_torrent` | Side-effect command; writes a torrent file. | +| `import_tracker_statistics` | Side-effect maintenance command. | +| `seeder` | Side-effect load/seeding command. | +| `upgrade` | Side-effect migration command. | +| `share/container/entry_script_sh` | Orchestration entrypoint; stdout from helpers is captured inside command substitutions. | -Existing commands that predate this ADR and print raw text to stdout or stderr are non-conforming legacy commands, not precedent. They should be migrated as follow-up work. Any functional change, operator-documentation change, or automation reuse of those commands must bring them under this ADR. +--- + +## Redaction Policy + +JSON diagnostics make accidental secret exposure easier to automate. The +following redaction rules are applied before JSON stderr becomes the output +path: + +- Never log raw database URLs that contain credentials. Log a redacted form + with password, token, and query-secret components removed. +- Never log JWT secrets, private keys, admin tokens, session secrets, API keys, + SMTP passwords, or mailer credentials. +- Avoid putting secrets in error `Display` strings. Prefer typed error fields + that can be redacted before logging. +- Keep raw external utility stderr out of top-level diagnostic messages unless + it has been reviewed or wrapped as a field that can be redacted. + +Shared redaction helpers replace secret-like field names with `[redacted]` and +strip userinfo plus secret-bearing query parameters from database URLs before +they are logged. + +--- + +## Shared Rust CLI Infrastructure + +`packages/index-cli-common` (`torrust-index-cli-common`) provides the shared +scaffolding for the global contract so every Rust binary shares the same +implementation: + +- **JSON clap handling:** `parse_args_or_exit::()` wraps + `clap::Parser::try_parse()`. Help and version requests emit JSON control + records to stderr and exit 0; argv errors emit JSON diagnostic/control + records to stderr and exit 2; stdout remains empty. +- **JSON stderr control-plane emission:** A direct record helper for + control-plane output that does not depend on a tracing subscriber. Used for + clap help, clap parse errors, early startup failures, and panic hooks. +- **JSON panic hook:** `install_json_panic_hook(command_name)` emits one JSON + diagnostic record to stderr and terminates with exit code 1. It does not + call Rust's default panic hook. It is safe for non-main-thread panics: emit + a best-effort JSON diagnostic once, avoid waiting on other application + threads, and terminate the process without returning to the default panic + path. +- **JSON tracing on stderr:** Idempotent initialization (`try_init` or an + equivalent guard) so early startup and later application setup cannot + double-install a subscriber. Each tracing event is emitted as one complete + JSON line on stderr using a non-interleaving locked writer per event, even + when multiple tasks or threads log concurrently. +- **TTY refusal:** For commands with stdout result data. Exit code 2 with a + JSON stderr diagnostic. +- **Stdout JSON emission:** `emit()` writes exactly one JSON object plus one + trailing newline to stdout, called only after TTY refusal. +- **Command runners:** Small runner helpers for the two command classes: + stdout-producing single-object commands and no-stdout side-effect commands. +- **Tracing filter precedence:** A non-empty `RUST_LOG` environment variable + wins, otherwise `--debug` selects debug-level diagnostics, otherwise the + command's default level is used. +- **`--debug` flag:** Shared across all commands. +- **`ExitCode` centralization:** Direct `std::process::exit` usage is + centralized in this shared infrastructure. Binaries return `ExitCode` from + their own `main` function. + +--- + +## Implementation Summary + +### Rust helper binaries (`packages/index-*`) + +`torrust-index-auth-keypair`, `torrust-index-config-probe`, and +`torrust-index-health-check` use the shared JSON clap parser, install the +shared JSON panic hook, expose `--version` through clap metadata, keep their +stdout result schemas unchanged, and preserve TTY refusal for stdout result +data. `torrust-index-config-probe` no longer preserves Rust's default +plain-text panic output. + +### Central application logging + +Central application logging uses the shared JSON stderr tracing setup. The root +package depends on `torrust-index-cli-common`. Root binaries return explicit +`ExitCode` values at their `main` boundaries. + +### `parse_torrent` and `create_test_torrent` + +`parse_torrent` uses the shared JSON clap parser, JSON panic hook, and JSON +stderr tracing runner. It emits one JSON stdout result object and refuses +terminal stdout. `create_test_torrent` uses the same shared infrastructure and +remains a no-stdout side-effect command with JSON diagnostics on stderr. + +### `import_tracker_statistics`, `seeder`, and `upgrade` + +All three use the shared JSON clap parser, JSON panic hook, JSON stderr tracing +runner, empty stdout side-effect contract, structured tracing diagnostics, and +propagated command errors. The command-reachable tracker statistics and upgrade +modules no longer emit raw stream output or terminal color formatting. + +### Command-reachable shared libraries + +Server shutdown notices use structured tracing diagnostics. Mail template +initialization errors are returned to callers for JSON diagnostic reporting +instead of printing or exiting from the mailer library. `text-colorizer` is +removed from root runtime code. Color formatting is removed from +command-reachable modules. Library parsing helpers return errors and let +command callers decide how to report them. + +### Container entry script + +The shell entrypoint checks for `jq`, emits JSON diagnostics and debug phase +records on stderr, wraps validation failures in shared control-plane records, +captures expected utility stderr where the script controls the utility +invocation, and keeps helper stdout inside command substitutions. `set -x` +under `DEBUG=1` is replaced with explicit JSON debug records at phase +boundaries. Shell JSON diagnostics use `jq -cn --arg ...` for string escaping. +If `jq` is missing, one fixed, minimal JSON diagnostic is emitted to stderr +without interpolating untrusted values. File writes to `/etc/motd` and +`/etc/profile` remain plain text because they are not stream output. + +### Documentation and changelog + +Operator documentation and changelog entries are updated for the command-output +migrations. `README.md` command examples, `docs/containers.md`, +`upgrades/from_v1_0_0_to_v2_0_0/README.md`, and command module docs are +aligned with the migrated command behavior. `CHANGELOG.md` entries are marked +as breaking when command output changes can affect scripts that consumed +previous plain-text output. + +--- + +## Tests And Guards + +### Shared infrastructure tests (`packages/index-cli-common`) + +- JSON help records, JSON version records, JSON argv-error records, exit code + mapping, TTY-refusal records, stdout JSON emission, and the panic hook's JSON + shape. +- Schema/version fields on control-plane records. +- Redaction of common secret-bearing fields. +- `RUST_LOG`/`--debug` precedence. +- Concurrent JSON logging: records emitted from multiple threads or tasks are + each captured as one complete JSON object per stderr line. + +### Helper binary contract tests + +Success stdout shape, empty stdout on failure, JSON stderr diagnostics, clap +help JSON, clap version JSON, clap error JSON, and exit code 2 for usage +failures. + +### Root binary contract tests + +`parse_torrent` success/failure stdout shape. No-stdout commands keeping stdout +empty while logging JSON stderr. + +### Container entry-script tests + +`packages/index-entry-script` tests parse and assert JSON stderr records for +validation failures and status branches. + +### Workspace Clippy guards + +Configured through workspace lint levels in `Cargo.toml` (no separate +`clippy.toml`): + +- `clippy::print_stdout` and `clippy::print_stderr` are denied. Local + `#[allow]` annotations exist for Cargo build-script protocol output, + developer examples, out-of-scope test diagnostics, and the shared CLI + infrastructure's controlled stdout emitter. +- `clippy::exit` is denied. A local exception exists for the shared CLI + infrastructure's `exit_with` helper. + +### Binary-boundary regression test + +The root `cli_contract` integration test scans all in-scope binaries and fails +if a `main` boundary stops returning `ExitCode` or regresses to `Result` +termination, because Rust's default `Result` termination writes raw +`Error: ...` text to stderr. + +### TTY-refusal smoke tests + +Stdout-producing commands are tested for TTY refusal using a pseudo-terminal +library or tool such as `rexpect` or `portable-pty`. + +### Suggested verification commands + +```sh +cargo fmt --all +cargo check --workspace --all-targets --all-features 2>&1 | tee /tmp/adr010-cargo-check.log +cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tee /tmp/adr010-cargo-clippy.log +cargo test --workspace --all-targets --all-features 2>&1 | tee /tmp/adr010-cargo-test.log +cargo test --workspace --all-targets --all-features --release 2>&1 | tee /tmp/adr010-cargo-test-release.log +cargo check --workspace --all-targets --no-default-features 2>&1 | tee /tmp/adr010-cargo-check-no-default-features.log +cargo test --workspace --all-targets --no-default-features 2>&1 | tee /tmp/adr010-cargo-test-no-default-features.log +cargo test --doc --workspace --all-features 2>&1 | tee /tmp/adr010-cargo-test-doc.log +cargo doc --workspace --all-features --no-deps 2>&1 | tee /tmp/adr010-cargo-doc.log +``` + +--- ## Consequences -ADR-T-009 remains the historical record for why the helper binaries were extracted and why the first implementation exists. This ADR is the canonical application-wide output contract. +ADR-T-009 remains the historical record for why the helper binaries were +extracted and why the first implementation exists. This ADR is the canonical +application-wide output contract. + +New command-line entrypoints must state whether they emit stdout result data. If +they do, they must either use the default single-object JSON contract or +document a justified JSON exception. + +The main server and maintenance commands are governed by the same stdout/stderr +separation as the helper binaries. The difference is only whether they have +stdout result data. -New command-line entrypoints must state whether they emit stdout result data. If they do, they must either use the default single-object JSON contract or document a justified JSON exception. +All existing first-party command-line entrypoints now conform to this contract. +Commands that predate this ADR have been migrated; no non-conforming legacy +commands remain as precedent. -The main server and maintenance commands are governed by the same stdout/stderr separation as the helper binaries. The difference is only whether they have stdout result data. +The exact exit-code taxonomy for root maintenance commands beyond the baseline +`success`, `failure`, and `usage` classes is left to future command-specific +contracts. Existing helper-specific exit codes remain stable unless a +command-specific contract says otherwise. \ No newline at end of file diff --git a/docs/plans/adr-010-command-line-output-conformance-plan.md b/docs/plans/adr-010-command-line-output-conformance-plan.md deleted file mode 100644 index 5a295b3b..00000000 --- a/docs/plans/adr-010-command-line-output-conformance-plan.md +++ /dev/null @@ -1,616 +0,0 @@ -# Command-Line Output Conformance Plan - -**Status:** Draft plan -**Date:** 2026-05-13 -**Implements:** [ADR-T-010](../../adr/010-global-command-line-output-contract.md) -**Related:** [ADR-T-009](../../adr/009-container-infrastructure-refactor.md) - -This is an implementation plan for ADR-T-010, not a separate ADR. Its job is to -turn the decided repository-wide command-line output contract into concrete -code, documentation, and regression tests. - -## Current Implementation Status - -Stages 1 through 10 have landed for the shared Rust helper path, root command -migrations, command-reachable shared-library cleanup, container entry script, -documentation, and regression guards: - -- Stage 1 fixed the shared control-plane record shape, baseline exit classes, - helper stdout schemas, and redaction helpers. -- Stage 2 expanded `torrust-index-cli-common` with JSON `clap` handling, direct - JSON stderr control-plane emission, the JSON panic hook, idempotent JSON - stderr tracing with `RUST_LOG` / `--debug` precedence, a non-interleaving - stderr writer, and command runners. -- Stage 3 wired `torrust-index-auth-keypair`, `torrust-index-config-probe`, and - `torrust-index-health-check` to the expanded shared infrastructure. Their - help, version, argv errors, TTY refusal, and panic diagnostics are now JSON - control-plane records on stderr. -- Stage 4 switched central application logging to the shared JSON stderr - tracing setup, added the shared CLI contract crate to the root package, and - made root binaries return explicit `ExitCode` values at their `main` - boundaries. At that point, maintenance-command internals still remained - legacy output gaps pending their per-command migration stages. -- Stage 5 migrated `parse_torrent` and `create_test_torrent` to the shared JSON - clap parser, JSON panic hook, JSON stderr tracing runners, and focused CLI - contract tests. `parse_torrent` now emits one JSON stdout result object and - refuses terminal stdout; `create_test_torrent` remains a no-stdout side-effect - command. -- Stage 6 migrated `import_tracker_statistics`, `seeder`, and `upgrade` to the - shared JSON clap parser, JSON panic hook, JSON stderr tracing runner, empty - stdout side-effect contract, structured tracing diagnostics, and propagated - command errors. The command-reachable tracker statistics and upgrade modules - no longer emit raw stream output or terminal color formatting. -- Stage 7 removed the remaining raw stream output from command-reachable shared - libraries. Server shutdown notices now use structured tracing diagnostics, - and mail template initialization errors are returned to callers for JSON - diagnostic reporting instead of printing or exiting from the mailer library. -- Stage 8 migrated the container entry script and its host-side helper tests. - Shell diagnostics now use JSON stderr control-plane records, debug mode emits - explicit JSON phase records instead of `set -x`, expected validation failures - are reported as JSON diagnostics, and utility failures controlled by the - script are captured and re-emitted as JSON fields. -- Stage 9 updated the operator documentation and changelog entries for the - landed command-output migrations. -- Stage 10 added workspace Clippy guards for raw Rust stream output and direct - process exits, explicit out-of-scope test/build/example exceptions, and a - binary-boundary regression test that keeps in-scope `main` functions returning - `ExitCode` instead of `Result`. The Clippy guard is configured through - workspace lint levels in `Cargo.toml`; no separate `clippy.toml` is used. - -## Goal - -Bring every shipped, documented, or operator-facing first-party command-line -entrypoint into conformance with ADR-T-010. After this work, command stdout and -stderr are machine-readable streams: stdout is empty unless the command emits -result data, result data is JSON, diagnostics are JSON records on stderr, and -stdout-producing commands refuse to write result data directly to a terminal. - -## Scope - -In scope: - -- `src/main.rs` (`torrust-index` server binary). -- `src/bin/create_test_torrent.rs`. -- `src/bin/import_tracker_statistics.rs`. -- `src/bin/parse_torrent.rs`. -- `src/bin/seeder.rs`. -- `src/bin/upgrade.rs`. -- `packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs`. -- `packages/index-config-probe/src/bin/torrust-index-config-probe.rs`. -- `packages/index-health-check/src/bin/torrust-index-health-check.rs`. -- `share/container/entry_script_sh` and `share/container/entry_script_lib_sh`, - because the container entry script is a shipped first-party command - entrypoint. - -Command-reachable library paths that must also be cleaned up when they emit -operator-facing diagnostics: - -- `src/bootstrap/logging.rs`. -- `src/console/commands/seeder/app.rs`. -- `src/console/commands/seeder/logging.rs`. -- `src/console/commands/tracker_statistics_importer/app.rs`. -- `src/console/cronjobs/tracker_statistics_importer.rs`. -- `src/mailer.rs`. -- `src/tracker/statistics_importer.rs`. -- `src/upgrades/from_v1_0_0_to_v2_0_0/`. -- `src/utils/parse_torrent.rs`. -- `src/web/api/server/signals.rs`. - -Out of scope unless they become documented operator commands: - -- `build.rs` Cargo protocol output such as `cargo:rerun-if-changed=...`. -- Tests, benches, examples, and harnesses under `tests/`, `src/tests/`, and - package test directories. -- Developer-only scripts under `contrib/dev-tools/`. -- Library packages with no shipped binary entrypoint, such as Mudlark and - `render-text-as-image`. - -## Contract Baseline - -All migrated commands must share these baseline behaviours: - -- Exit code 0 means success. -- Exit code 1 means a runtime, startup, internal, or command execution failure - unless a command-specific contract documents a narrower non-usage code. -- Exit code 2 means command-line usage failure, including clap argv errors and - stdout TTY refusal. -- On failure, stdout is empty. -- Stderr is always JSON or empty. There is no TTY exemption for stderr; JSON - diagnostics remain JSON even when stderr is attached to a terminal. -- Commands with stdout result data refuse when stdout is attached to a terminal. - Commands with no stdout result data do not perform stdout TTY refusal. -- The in-scope stdout-producing commands use ADR-T-010's default single-object - stdout contract. None of them use the documented streaming NDJSON exception - unless a later command-specific contract explicitly says so. -- Help and version requests are JSON control-plane records on stderr. They do - not emit stdout result data and do not trigger stdout TTY refusal. -- Stderr records are NDJSON: one complete JSON object per line. Rust and shell - emitters must avoid partial writes that can interleave bytes from concurrent - records. -- Shared control-plane records, including help, version, usage errors, TTY - refusal, and panic diagnostics, include a schema/version field so scripts can - distinguish future contract revisions. -- Command-specific stdout result schemas should either include their own version - field or be documented as stable command contracts. - -## Stage 1 Contract Decisions - -The first implementation stage fixes the shared contract details that later -rollout stages wire into each binary. - -Shared stderr control-plane records use this top-level JSON shape: - -- `schema`: numeric shared control-plane record schema. The initial value is `1`. -- `command`: binary or entrypoint name. -- `kind`: one of `help`, `version`, `usage_error`, `tty_refusal`, `panic`, - `status`, or `diagnostic`. -- `message`: short human-readable message carried inside the JSON record. -- `fields`: optional kind-specific object tagged with `type`. - -The initial structured field variants are: - -- `help`: `text`. -- `version`: `version`. -- `usage_error`: `exit_code` and `clap_error_kind`. -- `tty_refusal`: `exit_code` and `stream`. -- `panic`: `exit_code`, `thread`, and `location`. Panic payloads are not part of - the shared record because they may contain secrets. - -The shared baseline exit-code classes are: - -- `success`: process status `0`. -- `failure`: process status `1`. -- `usage`: process status `2`. - -Command-specific non-usage exit codes may still be documented by the owning -command contract. For example, `torrust-index-config-probe` keeps its existing -configuration and probe failure codes until a command-specific contract changes -them. - -Stdout-producing command result schemas use a numeric top-level `schema` field. -The first-stage helper outputs are: - -- `torrust-index-auth-keypair`: `schema`, `private_key_pem`, and - `public_key_pem`. -- `torrust-index-config-probe`: `schema`, `database`, and `auth`. -- `torrust-index-health-check`: `schema`, `target`, `status`, and `elapsed_ms`. - -Shared redaction helpers apply the initial redaction policy for diagnostics: -secret-like field names are replaced with `[redacted]`, and database URLs have -userinfo plus secret-bearing query parameters removed before they are logged. - -## Redaction Policy - -JSON diagnostics are easier for operators and scripts to consume, but they also -make accidental secret exposure easier to automate. The migration must define -and apply a redaction policy before JSON stderr becomes the default path. - -Required redaction rules: - -- Never log raw database URLs that contain credentials. Either omit them or log - a redacted form with password, token, and query-secret components removed. -- Never log JWT secrets, private keys, admin tokens, session secrets, API keys, - SMTP passwords, or mailer credentials. -- Avoid putting secrets in error `Display` strings. Prefer typed error fields - that can be redacted before logging. -- Keep raw external utility stderr out of top-level diagnostic messages unless - it has been reviewed or wrapped as a field that can be redacted. -- Add focused tests or review guards for the most likely secret-bearing fields - before the rollout switches operator commands to JSON stderr by default. - -## Command Output Classification - -Commands with stdout result data: - -- `torrust-index-auth-keypair`: emits one JSON object containing the generated - key pair. -- `torrust-index-config-probe`: emits one JSON object containing the resolved - container-relevant configuration subset. -- `torrust-index-health-check`: emits one JSON object containing the health - result. -- `parse_torrent`: emits one JSON object containing the result schema version, - decoded torrent, original v1 info hash, and stable parse metadata such as - input byte length. Do not include raw filesystem paths in the stable result - schema unless the command contract also defines explicit path encoding rules. - -Commands with no stdout result data: - -- `torrust-index`: long-running server; stdout remains empty and logs go to - stderr as JSON. -- `create_test_torrent`: side-effect command; write the torrent file and emit - status diagnostics on stderr as JSON. If a future caller needs the generated - path as data, promote that to stdout result data and add TTY refusal at that - time. For this migration, it remains a no-stdout command. -- `import_tracker_statistics`: side-effect maintenance command; stdout remains - empty. -- `seeder`: side-effect load/seeding command; stdout remains empty. -- `upgrade`: side-effect migration command; stdout remains empty. -- `share/container/entry_script_sh`: orchestration entrypoint; stdout remains - empty except for stdout captured from helper binaries inside command - substitutions. - -## Shared Rust CLI Infrastructure - -Update `packages/index-cli-common` so every Rust binary can share the same -contract implementation instead of open-coding it. - -Stage 2 status: implemented. The shared crate now owns the control-plane record -writer, `parse_args_or_exit::()`, the JSON panic hook, `RUST_LOG` / `--debug` -tracing precedence, locked stderr JSON tracing, and stdout/no-stdout command -runners. The helper binaries use these entrypoints; root binaries will migrate -in later stages. - -Required changes: - -- Define the shared JSON control-plane record shape, including a schema/version - field, command name, record kind, message, and structured fields for usage - errors, TTY refusal, and panic diagnostics. -- Add a JSON stderr record helper for control-plane output that does not depend - on a tracing subscriber already being installed. This is needed for clap help, - clap parse errors, early startup failures, and panic hooks. -- Add a `parse_args_or_exit::()` helper around `clap::Parser::try_parse()`: - help and version requests emit JSON control records to stderr and exit 0; - argv errors emit JSON diagnostic/control records to stderr and exit 2; - stdout remains empty. -- Replace all raw clap help/error paths in binaries with the shared parse - helper. -- Keep `emit()` as the single-object stdout JSON writer, but ensure all callers - use it only after TTY refusal. -- Keep TTY refusal exit code 2 for stdout-producing commands, and make the - refusal diagnostic a JSON stderr record. -- Add an `install_json_panic_hook(command_name)` helper. The hook must not call - Rust's default panic hook, because the default hook writes plain text to - stderr. It should emit one JSON diagnostic record to stderr and terminate with - exit code 1. -- Make the panic hook safe for non-main-thread panics: emit a best-effort JSON - diagnostic once, avoid waiting on other application threads, and terminate the - process without returning to the default panic path. -- Make JSON tracing setup write to stderr explicitly and use an idempotent - initialization path (`try_init` or an equivalent guard) so early startup and - later application setup cannot double-install a subscriber. -- Ensure each tracing event is emitted as one complete JSON line on stderr, even - when multiple tasks or threads log concurrently. Use a writer strategy that - serializes each completed record, such as a locked writer per event or an - equivalent non-interleaving writer, rather than relying on ad-hoc writes to a - shared stream. -- Add small runner helpers for the two command classes: stdout-producing - single-object commands, and no-stdout side-effect commands. -- Centralize direct `std::process::exit` usage in this shared infrastructure - where practical, so binaries return `ExitCode` from their own `main` function. - -## Root Crate Wiring - -Update the root package so the application binaries can use the shared CLI -contract crate. - -Required changes: - -- Add `torrust-index-cli-common` as a root dependency in `Cargo.toml`. -- Remove `text-colorizer` from root runtime code once terminal color output is - gone. Keep it only if a test-only or non-command path still needs it. -- Remove color formatting from command-reachable modules, including - `src/console/cronjobs/tracker_statistics_importer.rs`, - `src/tracker/statistics_importer.rs`, and - `src/upgrades/from_v1_0_0_to_v2_0_0/upgrader.rs`. -- Ensure root binaries return `std::process::ExitCode` rather than `Result` from - `main`, so Rust's `Termination` implementation cannot print raw `Error: ...` - text to stderr. -- Convert startup functions that currently panic or unwrap at the binary - boundary into `Result`-returning functions whose errors are logged as JSON and - mapped to process exit codes. - -## Application Logging - -Update `src/bootstrap/logging.rs` and any command-specific logging modules so -all operator-facing diagnostics use JSON tracing on stderr. - -Required changes: - -- Replace the default, pretty, and compact command-line logging styles with JSON - stderr logging for application binaries. If human-formatted test logs are - still useful, keep them behind test-only helpers outside the operator command - path. -- Make `src/console/commands/seeder/logging.rs` delegate to the central JSON - stderr setup or remove the module. -- Initialize logging before any code path can emit diagnostics. -- Preserve the current operator intent of `--debug` and add `RUST_LOG` as the - more expressive override: when `RUST_LOG` is set and non-empty, use it as the - filter directive; otherwise, `--debug` raises the command's default filter to - debug; otherwise, use the command or server configuration default. Document - this precedence and make all paths produce JSON stderr records. -- Avoid emitting ANSI color escape sequences inside log messages. Prefer - structured fields such as `torrent_id`, `tracker_url`, `limit`, and - `elapsed_ms`. -- Apply the redaction policy to tracing fields and error messages before they - are serialized. - -## Main Server Binary - -Update `src/main.rs` and the startup path used by `torrust-index`. - -Stage 7 status: implemented for command-reachable shared-library output cleanup. -`src/web/api/server/signals.rs` now reports shutdown notices through structured -tracing, and `src/mailer.rs` returns template initialization errors to callers -instead of printing or exiting from the library. - -Required changes: - -- Install the JSON panic hook at process start. -- Initialize JSON stderr diagnostics before configuration loading can fail. -- Add a non-panicking configuration loader, for example - `try_initialize_configuration()`, and have `main` log loader failures as JSON - before returning a non-zero exit code. -- Change `app::run` to return `Result` or otherwise - convert startup failures into JSON diagnostics rather than `expect`/`unwrap` - panics. -- Replace `assert!` and `expect` at the binary boundary with JSON diagnostics - and explicit exit codes. -- Replace `println!` in `src/web/api/server/signals.rs` with a tracing event - that records the received shutdown signal or phase on JSON stderr. -- Replace raw output and process exits in `src/mailer.rs` with structured errors - or tracing errors that flow to the binary boundary. -- Remove raw output from `src/utils/parse_torrent.rs`; library parsing helpers - should return errors and let command callers decide how to report them. - -## Helper Binaries - -Update the helper binaries under `packages/index-*`. - -Stage 3 status: implemented for the three container helpers. They use the shared -JSON clap parser, install the shared JSON panic hook, expose `--version` through -clap metadata, keep their stdout result schemas unchanged, and preserve TTY -refusal for stdout result data. `torrust-index-config-probe` no longer preserves -Rust's default plain-text panic output. - -Required changes: - -- Use the shared JSON clap parser in: - `packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs`, - `packages/index-config-probe/src/bin/torrust-index-config-probe.rs`, and - `packages/index-health-check/src/bin/torrust-index-health-check.rs`. -- Install the shared JSON panic hook in each helper. -- Replace `torrust-index-config-probe`'s current panic hook, which preserves the - default raw panic output, with the shared JSON hook. -- Keep stdout success output as exactly one JSON object plus one trailing - newline. -- Keep failure stdout empty. -- Keep TTY refusal for all three helpers because they emit stdout result data. - -## Root `src/bin` Binaries - -Update every root maintenance and diagnostic binary. - -Stage 5 status: implemented for `src/bin/parse_torrent.rs` and -`src/bin/create_test_torrent.rs`. - -Stage 6 status: implemented for `src/bin/import_tracker_statistics.rs`, -`src/bin/seeder.rs`, `src/bin/upgrade.rs`, and their command-reachable tracker -statistics, seeder, and upgrade modules. - -Required changes for `src/bin/parse_torrent.rs`: - -- Replace hand-rolled `std::env::args()` parsing with clap plus the shared JSON - clap wrapper. -- Install JSON tracing and the JSON panic hook. -- Refuse stdout TTY because this command emits stdout result data. -- Remove progress `println!` calls. -- Emit one JSON object on stdout on success, containing the result schema - version, decoded torrent, original v1 info hash, and input byte length. -- On invalid bencode, invalid torrent data, I/O errors, or serialization errors, - leave stdout empty, emit JSON diagnostics on stderr, and exit non-zero. - -Required changes for `src/bin/create_test_torrent.rs`: - -- Replace hand-rolled argv parsing with clap plus the shared JSON clap wrapper. -- Install JSON tracing and the JSON panic hook. -- Keep stdout empty. -- Replace usage `eprintln!`, `panic!`, and any future status output with JSON - diagnostics on stderr. -- Return explicit exit codes for invalid arguments, encode errors, file creation - errors, and write errors. -- Log the generated torrent path as a JSON stderr status record if operators - need confirmation. - -Required changes for `src/bin/import_tracker_statistics.rs` and its reachable -modules: - -- Add clap parsing with the shared JSON clap wrapper, even though the command - currently takes no arguments, so `--help` and unknown flags are JSON. -- Install JSON tracing and the JSON panic hook in the binary entrypoint. -- Keep stdout empty. -- Replace `println!`, `eprintln!`, colored strings, `expect`, and raw parse - failures in these paths with tracing events and `Result` propagation: - `src/console/commands/tracker_statistics_importer/app.rs`, - `src/console/cronjobs/tracker_statistics_importer.rs`, and - `src/tracker/statistics_importer.rs`. -- Convert database connection and import failures into JSON diagnostics and - explicit exit codes. - -Required changes for `src/bin/seeder.rs` and -`src/console/commands/seeder/app.rs`: - -- Install JSON tracing and the JSON panic hook in the binary entrypoint. -- Use the shared JSON clap wrapper for help and argv errors. -- Keep stdout empty. -- Replace the remaining `print!` error path with a structured tracing error event. -- Remove terminal color formatting from log messages. -- Replace `expect`, `unwrap`, and `panic!` in the command path with propagated - errors that the binary logs as JSON. -- Return `ExitCode` from `main` instead of `Result`. - -Required changes for `src/bin/upgrade.rs` and the v1-to-v2 upgrade modules: - -- Replace hand-rolled argv parsing with clap plus the shared JSON clap wrapper. -- Install JSON tracing and the JSON panic hook in the binary entrypoint. -- Keep stdout empty. -- Replace all `println!` and `eprintln!` calls under - `src/upgrades/from_v1_0_0_to_v2_0_0/` with tracing events. -- Remove terminal color formatting from diagnostics. -- Convert database open, migration, truncation, transfer, and file-read failures - into propagated errors that the binary logs as JSON. -- Replace `unwrap`, `expect`, and assertion failures in the command path with - typed errors where practical. For invariant violations that remain panics, the - JSON panic hook must still prevent raw stderr output. - -## Container Entry Script - -Update `share/container/entry_script_sh` and `share/container/entry_script_lib_sh`. - -Stage 8 status: implemented. The shell entrypoint now checks for `jq`, emits -JSON diagnostics and debug phase records on stderr, wraps validation failures in -shared control-plane records, captures expected utility stderr where the script -controls the utility invocation, and keeps helper stdout inside command -substitutions. The `packages/index-entry-script` host-side tests now parse and -assert JSON stderr records for validation failures and status branches. - -Required changes: - -- Add POSIX-shell JSON diagnostic helpers, for example `json_log` and - `json_error_exit`, that write one JSON object per line to stderr. Because the - runtime image already ships `jq`, prefer `jq -cn --arg ...` for string escaping - rather than hand-built JSON. -- Check for `jq` before any helper depends on it. If `jq` is missing or cannot - run, emit one fixed, minimal JSON diagnostic to stderr without interpolating - untrusted values, then exit non-zero. -- Include the shared schema/version field in shell control-plane records. -- Replace every `echo ... >&2` diagnostic with the JSON helper. -- Replace informational `echo`/`printf` diagnostics in helper functions with JSON - stderr records. File writes to `/etc/motd` and `/etc/profile` are not stream - output and can remain plain text. -- Remove `set -x` under `DEBUG=1`. Replace it with explicit JSON debug records - at phase boundaries that are useful to operators. -- Add failure handling around external utilities that may emit raw stderr on - expected operator errors (`jq`, `addgroup`, `adduser`, `install`, `chown`, - `chmod`, `mkdir`, `rm`, and `su-exec`). Capture their stderr where practical, - redact it when needed, and re-emit it as JSON fields. -- Add a trap for unexpected shell failures that emits a JSON stderr diagnostic - with the failing line or phase before exiting non-zero. -- Keep helper stdout captured only in command substitutions. Do not forward - helper stdout directly to the terminal. -- Where pipeline status matters, split commands or capture statuses explicitly - instead of depending on non-POSIX shell features. -- Update `packages/index-entry-script` tests so validation failures assert JSON - stderr records rather than plain text. - -## Documentation Updates - -Update operator documentation after the behavior changes. - -Current documentation status: the shared contract shape, helper stdout result -schemas, expanded Rust CLI infrastructure, helper-binary wiring state, -stage-four server logging / root `ExitCode` boundary state, the stage-five -`parse_torrent` / `create_test_torrent` migration, the stage-six root -maintenance command migration, the stage-seven command-reachable shared-library -cleanup, and the stage-eight container entry-script migration have been -documented. The stage-nine documentation pass and stage-ten regression guard -implementation have also been documented. Later documentation work should focus -on future command-specific contracts rather than re-describing completed rollout -stages as pending. - -Documentation maintenance requirements: - -- Keep `README.md` command examples aligned with each command's current - stdout/stderr class. -- Keep `docs/containers.md` aligned with JSON stderr diagnostics, stdout result - data, helper TTY refusal, and recommended inspection patterns such as piping - stdout result data to `jq`. -- Keep `upgrades/from_v1_0_0_to_v2_0_0/README.md` aligned with `upgrade`'s JSON - stderr diagnostics and empty stdout contract. -- Keep command module docs aligned with the migrated command behavior, - especially tracker statistics importer and upgrade docs. -- Keep `CHANGELOG.md` entries marked as breaking when command output changes can - affect scripts that consumed previous plain-text output. - -## Tests And Guards - -Add focused conformance tests near the command code and one broad guard to catch -future regressions. - -Stage 10 status: implemented for the broad guard layer. Workspace Clippy now -denies raw Rust stdout/stderr print macros and direct `std::process::exit` -outside explicit exceptions; test-only, build-script protocol, developer-example, -and shared CLI infrastructure exceptions are annotated locally. The guard is -configured with workspace lint levels in `Cargo.toml`; no separate Clippy -configuration file is used. The root `cli_contract` integration test scans all -in-scope binaries and fails if a `main` boundary stops returning `ExitCode` or -regresses to `Result` termination. - -Required tests: - -- `packages/index-cli-common` tests for JSON help records, JSON version records, - JSON argv-error records, exit code mapping, TTY-refusal records, stdout JSON - emission, and the panic hook's JSON shape. -- Shared infrastructure tests for schema/version fields on control-plane - records, redaction of common secret-bearing fields, `RUST_LOG`/`--debug` - precedence, and concurrent JSON logging. The concurrency test should emit - records from multiple threads or tasks and assert that every captured stderr - line round-trips as one complete JSON object. -- Helper binary contract tests for success stdout shape, empty stdout on - failure, JSON stderr diagnostics, clap help JSON, clap version JSON, clap error - JSON, and exit code 2 for usage failures. -- Root binary contract tests for `parse_torrent` success/failure stdout shape, - and for no-stdout commands keeping stdout empty while logging JSON stderr. -- Container entry-script tests in `packages/index-entry-script` for JSON stderr - on each validation failure branch. -- Workspace clippy guards for raw stream output in shipped command paths. The - implemented guard denies `clippy::print_stdout` and `clippy::print_stderr` - from workspace lint levels in `Cargo.toml`, with local `#[allow]` annotations - for Cargo build-script protocol output, developer examples, and out-of-scope - test diagnostics. -- A regression test for `main() -> Result` in in-scope binaries, because Rust's - default `Result` termination writes raw text on failure. -- A lint-backed guard for `std::process::exit` outside shared CLI infrastructure - and shell entry scripts. The implemented guard denies `clippy::exit` from - workspace lint levels in `Cargo.toml`, with a local exception for the shared - CLI infrastructure's `exit_with` helper. -- TTY-refusal smoke tests for stdout-producing commands. Use a pseudo-terminal - library or tool such as `rexpect` or `portable-pty` if in-process Rust tests - cannot reliably allocate a TTY. - -Suggested verification commands: - -```sh -cargo fmt --all -cargo check --workspace --all-targets --all-features 2>&1 | tee /tmp/adr010-cargo-check.log -cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tee /tmp/adr010-cargo-clippy.log -cargo test --workspace --all-targets --all-features 2>&1 | tee /tmp/adr010-cargo-test.log -cargo test --workspace --all-targets --all-features --release 2>&1 | tee /tmp/adr010-cargo-test-release.log -cargo check --workspace --all-targets --no-default-features 2>&1 | tee /tmp/adr010-cargo-check-no-default-features.log -cargo test --workspace --all-targets --no-default-features 2>&1 | tee /tmp/adr010-cargo-test-no-default-features.log -cargo test --doc --workspace --all-features 2>&1 | tee /tmp/adr010-cargo-test-doc.log -cargo doc --workspace --all-features --no-deps 2>&1 | tee /tmp/adr010-cargo-doc.log -``` - -After each command completes, grep the temp log for failures or warnings before -summarizing results, following the repository test-running convention. - -## Rollout Order - -Current status: steps 1 through 10 have landed. Documentation and changelog -entries for the shared-helper stages, the stage-four root logging / -binary-boundary rollout, the stage-five root binary migration, the stage-six -root maintenance command migration, the stage-seven shared-library cleanup, the -stage-eight container entry-script migration, and the stage-ten regression -guards have been updated. Later operator-visible migrations still need their own -documentation and changelog updates when they land. - -1. Finalize the shared control-plane record shape, command-specific result - schema details, exit-code mapping, and redaction rules. -2. Extend `torrust-index-cli-common` with JSON clap handling, JSON panic hooks, - idempotent JSON stderr tracing, redaction helpers, and command runners. -3. Migrate the three helper binaries to the expanded shared infrastructure. -4. Switch central application logging to JSON stderr and make root binaries use - `ExitCode` boundaries. -5. Migrate `parse_torrent` and `create_test_torrent`. -6. Migrate `import_tracker_statistics`, `seeder`, and `upgrade`, including their - command-reachable modules. -7. Remove raw output from shared libraries reached by command paths. -8. Migrate the container entry script and its tests. -9. Update documentation and changelog. -10. Add regression guards and run the verification suite. - -## Open Decisions - -- The exact exit-code taxonomy for root maintenance commands beyond the baseline - `success`, `failure`, and `usage` classes. Existing helper-specific exit codes - should remain stable unless a command-specific contract says otherwise. From 503e90e7946d042d8333162b835f60c048174431 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 19:51:17 +0200 Subject: [PATCH 12/13] fix(container): decouple debug runtime binary from nextest archive Disable incremental compilation and dev/test debuginfo for the debug dependency and nextest archive stages so the combined Cargo target/archive layer stays under common builder storage limits. Build the `torrust-index` debug runtime binary in a separate stage with those profile overrides unset, then copy it into the debug image after the tested helper artifacts from `test_debug`. --- Containerfile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Containerfile b/Containerfile index 42705b74..c481085c 100644 --- a/Containerfile +++ b/Containerfile @@ -80,6 +80,12 @@ RUN cargo chef prepare --recipe-path /build/recipe.json ## Cook (debug) FROM chef AS dependencies_debug WORKDIR /build/src +# The debug archive stage holds both the Cargo target directory and the +# nextest archive in one layer. Full debuginfo makes that layer exceed +# common builder storage limits while not changing the test surface. +ENV CARGO_INCREMENTAL=0 \ + CARGO_PROFILE_DEV_DEBUG=0 \ + CARGO_PROFILE_TEST_DEBUG=0 COPY --from=recipe /build/recipe.json /build/recipe.json RUN cargo chef cook --workspace --all-targets --all-features --recipe-path /build/recipe.json RUN cargo nextest archive --workspace --all-targets --all-features --archive-file /build/temp.tar.zst ; rm -f /build/temp.tar.zst @@ -98,6 +104,13 @@ WORKDIR /build/src COPY . /build/src RUN cargo nextest archive --workspace --all-targets --all-features --archive-file /build/torrust-index-debug.tar.zst +## Build Runtime Binary (debug) +FROM dependencies_debug AS build_debug_runtime +WORKDIR /build/src +COPY . /build/src +RUN unset CARGO_PROFILE_DEV_DEBUG CARGO_PROFILE_TEST_DEBUG; \ + cargo build --package torrust-index --all-features --bin torrust-index + ## Build Archive (release) FROM dependencies AS build WORKDIR /build/src @@ -314,6 +327,8 @@ ENV TORRUST_INDEX_CONFIG_TOML_PATH=/etc/torrust/index/index.toml \ EXPOSE 3001/tcp 3002/tcp VOLUME ["/var/lib/torrust/index","/var/log/torrust/index","/etc/torrust/index"] COPY --from=test_debug /app/ /usr/ +COPY --from=build_debug_runtime --chmod=0755 --chown=0:0 \ + /build/src/target/debug/torrust-index /usr/bin/torrust-index # jq binary for entry-script JSON consumption (§2.2 step 4). # Root-only (0500) — same posture as busybox and su-exec. # The two shared libraries (libjq, libonig) are required at From 5a6dab0ff0ac4079f45b9744a5ff8e3ca134e931 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 14 May 2026 21:55:31 +0200 Subject: [PATCH 13/13] feat(cli): surface panic payloads in debug diagnostics Allow ADR-T-010 panic control-plane records to include string panic payloads when debug diagnostics are enabled, while continuing to omit the payload field for normal runs. Wire the debug flag through the shared CLI runners and config probe, and add coverage for the payload gate, JSON serialization, and string payload extraction. --- packages/index-cli-common/src/lib.rs | 43 ++++++++++++++-- packages/index-cli-common/src/tests/mod.rs | 51 +++++++++++++++++-- .../src/bin/torrust-index-config-probe.rs | 2 + 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/index-cli-common/src/lib.rs b/packages/index-cli-common/src/lib.rs index e99f0de3..0983c0e7 100644 --- a/packages/index-cli-common/src/lib.rs +++ b/packages/index-cli-common/src/lib.rs @@ -31,6 +31,7 @@ pub const CONTROL_PLANE_SCHEMA: u32 = 1; pub const REDACTED: &str = "[redacted]"; static PANIC_REPORTED: AtomicBool = AtomicBool::new(false); +static PANIC_PAYLOAD_REPORTING_ENABLED: AtomicBool = AtomicBool::new(true); static STDERR_WRITE_LOCK: Mutex<()> = Mutex::new(()); /// Baseline exit-code classes shared by ADR-T-010 command-line tools. @@ -116,11 +117,13 @@ pub enum ControlPlaneFields { UsageError { exit_code: u8, clap_error_kind: String }, /// Details for stdout TTY refusal. TtyRefusal { exit_code: u8, stream: StandardStream }, - /// Details for a panic diagnostic. The panic payload is deliberately omitted. + /// Details for a panic diagnostic. The panic payload is only exposed when debug diagnostics are enabled. Panic { exit_code: u8, thread: Option, location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + payload: Option, }, } @@ -207,7 +210,7 @@ impl ControlPlaneRecord { /// Build a panic diagnostic record. #[must_use] - pub fn panic(command: &str, thread: Option<&str>, location: Option<&str>) -> Self { + pub fn panic(command: &str, thread: Option<&str>, location: Option<&str>, payload: Option<&str>) -> Self { Self::new( command, ControlPlaneRecordKind::Panic, @@ -216,6 +219,7 @@ impl ControlPlaneRecord { exit_code: CommandExit::Failure.code(), thread: thread.map(str::to_string), location: location.map(str::to_string), + payload: payload.map(str::to_string), }), ) } @@ -421,6 +425,9 @@ fn level_directive(level: tracing::Level) -> String { /// /// Returns [`CliExit`] when parsing should stop and the caller should write the /// enclosed record to stderr before exiting with the enclosed exit class. +/// The error value is intentionally returned by value because this path is only +/// used when parsing stops, and boxing it would complicate the public helper API. +#[allow(clippy::result_large_err)] pub fn parse_args_from(args: I) -> Result where T: Parser, @@ -531,7 +538,10 @@ pub fn install_json_panic_hook(command_name: &str) { let location = panic_info .location() .map(|location| format!("{}:{}:{}", location.file(), location.line(), location.column())); - let record = ControlPlaneRecord::panic(&command_name, thread_name, location.as_deref()); + let payload = panic_payload_reporting_enabled() + .then(|| panic_payload_message(panic_info)) + .flatten(); + let record = ControlPlaneRecord::panic(&command_name, thread_name, location.as_deref(), payload); let _ignored = try_emit_control_plane_record(&record); } @@ -539,6 +549,30 @@ pub fn install_json_panic_hook(command_name: &str) { })); } +/// Enable or disable string panic payloads in JSON panic diagnostics. +/// +/// Payload reporting starts enabled so panics before argument parsing still +/// include their string payload. Call this with the parsed `--debug` value once +/// arguments are available. +pub fn set_panic_payload_reporting_enabled(enabled: bool) { + PANIC_PAYLOAD_REPORTING_ENABLED.store(enabled, Ordering::SeqCst); +} + +fn panic_payload_reporting_enabled() -> bool { + PANIC_PAYLOAD_REPORTING_ENABLED.load(Ordering::SeqCst) +} + +fn panic_payload_message<'a>(info: &'a std::panic::PanicHookInfo<'_>) -> Option<&'a str> { + panic_payload_message_from_payload(info.payload()) +} + +fn panic_payload_message_from_payload(payload: &(dyn std::any::Any + Send)) -> Option<&str> { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) +} + /// Exit the current process with an ADR-T-010 exit class. #[allow(clippy::exit)] pub fn exit_with(exit: CommandExit) -> ! { @@ -633,6 +667,7 @@ where CommandError: std::fmt::Display, Run: FnOnce() -> Result, { + set_panic_payload_reporting_enabled(debug); install_json_panic_hook(command_name); init_json_tracing_with_debug(debug, default_level); @@ -671,6 +706,7 @@ where CommandError: std::fmt::Display, Run: FnOnce() -> Result<(), CommandError>, { + set_panic_payload_reporting_enabled(debug); install_json_panic_hook(command_name); init_json_tracing_with_debug(debug, default_level); @@ -699,6 +735,7 @@ where Run: FnOnce() -> RunFuture, RunFuture: Future>, { + set_panic_payload_reporting_enabled(debug); install_json_panic_hook(command_name); init_json_tracing_with_debug(debug, default_level); diff --git a/packages/index-cli-common/src/tests/mod.rs b/packages/index-cli-common/src/tests/mod.rs index 9df850e6..73029ecb 100644 --- a/packages/index-cli-common/src/tests/mod.rs +++ b/packages/index-cli-common/src/tests/mod.rs @@ -13,7 +13,10 @@ //! | `json_line_writer_appends_newline` | JSON record helper writes one complete line. | //! | `usage_error_record_carries_fields` | Usage records include exit code and clap kind. | //! | `tty_refusal_record_carries_fields` | TTY refusal records identify stdout and code 2. | -//! | `panic_record_omits_payload` | Panic records avoid serialising panic payloads. | +//! | `panic_record_omits_payload_without_debug` | Panic records hide payloads without debug. | +//! | `panic_record_carries_debug_payload` | Panic records can expose string payloads. | +//! | `panic_payload_reporting_defaults_enabled_then_follows_debug_flag` | Startup payload gate behavior. | +//! | `panic_payload_message_extracts_string_payloads` | String panic payloads are downcast. | //! | `parse_args_from_returns_help_record` | Clap help becomes JSON stderr control data. | //! | `parse_args_from_returns_version_record` | Clap version becomes JSON stderr control data. | //! | `parse_args_from_returns_usage_record` | Clap argv errors become JSON usage records. | @@ -42,8 +45,8 @@ use serde_json::json; use crate::{ BaseArgs, CONTROL_PLANE_SCHEMA, CommandExit, ControlPlaneFields, ControlPlaneRecord, ControlPlaneRecordKind, REDACTED, - StandardStream, TracingFilterSource, parse_args_from, redact_database_url, redact_field_value, tracing_filter_from_rust_log, - write_json_line, + StandardStream, TracingFilterSource, panic_payload_message_from_payload, panic_payload_reporting_enabled, parse_args_from, + redact_database_url, redact_field_value, set_panic_payload_reporting_enabled, tracing_filter_from_rust_log, write_json_line, }; /// A `Write` that fails every call with `BrokenPipe`. @@ -235,8 +238,8 @@ fn tty_refusal_record_carries_fields() { } #[test] -fn panic_record_omits_payload() { - let record = ControlPlaneRecord::panic("fixture", Some("main"), Some("src/main.rs:12:34")); +fn panic_record_omits_payload_without_debug() { + let record = ControlPlaneRecord::panic("fixture", Some("main"), Some("src/main.rs:12:34"), None); let value = serde_json::to_value(record).unwrap(); assert_eq!(value["kind"], json!("panic")); @@ -246,6 +249,44 @@ fn panic_record_omits_payload() { assert!(value["fields"].get("payload").is_none()); } +#[test] +fn panic_record_carries_debug_payload() { + let record = ControlPlaneRecord::panic( + "fixture", + Some("main"), + Some("src/main.rs:12:34"), + Some("panic with \"quoted\" detail"), + ); + let line = serde_json::to_string(&record).unwrap(); + + assert!(line.contains(r#""payload":"panic with \"quoted\" detail""#)); + + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["fields"]["payload"], json!("panic with \"quoted\" detail")); +} + +#[test] +fn panic_payload_reporting_defaults_enabled_then_follows_debug_flag() { + assert!(panic_payload_reporting_enabled()); + + set_panic_payload_reporting_enabled(false); + assert!(!panic_payload_reporting_enabled()); + + set_panic_payload_reporting_enabled(true); + assert!(panic_payload_reporting_enabled()); +} + +#[test] +fn panic_payload_message_extracts_string_payloads() { + let borrowed_payload: &(dyn std::any::Any + Send) = &"borrowed panic"; + let owned_payload: &(dyn std::any::Any + Send) = &String::from("owned panic"); + let numeric_payload: &(dyn std::any::Any + Send) = &1_u8; + + assert_eq!(panic_payload_message_from_payload(borrowed_payload), Some("borrowed panic")); + assert_eq!(panic_payload_message_from_payload(owned_payload), Some("owned panic")); + assert_eq!(panic_payload_message_from_payload(numeric_payload), None); +} + #[test] fn parse_args_from_returns_help_record() { let Err(exit) = parse_args_from::(["fixture-helper", "--help"]) else { diff --git a/packages/index-config-probe/src/bin/torrust-index-config-probe.rs b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs index c145555b..05f3dd98 100644 --- a/packages/index-config-probe/src/bin/torrust-index-config-probe.rs +++ b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs @@ -10,6 +10,7 @@ use std::process::ExitCode; use clap::Parser; use torrust_index_cli_common::{ BaseArgs, emit, init_json_tracing_with_debug, install_json_panic_hook, parse_args_or_exit, refuse_if_stdout_is_tty, + set_panic_payload_reporting_enabled, }; use torrust_index_config::{DEFAULT_CONFIG_TOML_PATH, Info, load_settings}; use torrust_index_config_probe::{ProbeError, probe}; @@ -32,6 +33,7 @@ fn main() -> ExitCode { install_json_panic_hook(COMMAND_NAME); let args = parse_args_or_exit::(); + set_panic_payload_reporting_enabled(args.base.debug); init_json_tracing_with_debug(args.base.debug, tracing::Level::INFO); refuse_if_stdout_is_tty(COMMAND_NAME);