From 11455aef4c030d6a7696e46f78254e05a7e5fb2b Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 07:12:20 +0900 Subject: [PATCH] refactor: route command output through explicit writers --- docs/specs/architecture.md | 10 +- docs/specs/cli-runtime.md | 7 +- docs/specs/cloudflared.md | 6 +- docs/specs/docker-nvidia.md | 4 - docs/specs/doctor-schedule.md | 3 +- docs/specs/engine.md | 5 +- docs/specs/fleet.md | 3 +- docs/specs/gpu-allocation.md | 3 +- docs/specs/host-base.md | 4 - docs/specs/self-update.md | 3 +- docs/specs/ssh-access.md | 4 - docs/specs/state-rollback.md | 3 +- docs/specs/tenant.md | 3 +- docs/specs/users.md | 3 +- internal/cli/apply.go | 73 +++--- internal/cli/backup.go | 62 ++--- internal/cli/check.go | 4 +- internal/cli/doctor.go | 3 +- internal/cli/fleet.go | 11 +- internal/cli/json_escaping_test.go | 31 +++ internal/cli/output.go | 6 +- internal/cli/report_helpers_test.go | 2 +- internal/cli/rollback.go | 2 +- internal/cli/runtime.go | 2 +- internal/cli/status.go | 1 + internal/cli/tenant.go | 9 +- .../cli/testdata/output-t4/base.after.golden | 10 + .../cli/testdata/output-t4/base.before.golden | 10 + .../cli/testdata/output-t4/dgx.after.golden | 30 +++ .../cli/testdata/output-t4/dgx.before.golden | 30 +++ .../cli/testdata/output-t4/full.after.golden | 19 ++ .../cli/testdata/output-t4/full.before.golden | 19 ++ .../output-t4/gpu-server.after.golden | 24 ++ .../output-t4/gpu-server.before.golden | 24 ++ .../testdata/output-t4/minimal.after.golden | 16 ++ .../testdata/output-t4/minimal.before.golden | 16 ++ internal/cli/testdata/output-t4/results.json | 98 ++++++++ .../cli/testdata/output-t4/rocky.after.golden | 15 ++ .../testdata/output-t4/rocky.before.golden | 15 ++ internal/cli/tunnel.go | 18 +- internal/cli/upgrade.go | 36 +-- internal/cli/user.go | 6 +- internal/cli/writer_routing_test.go | 192 +++++++++++++++ internal/exec/backup.go | 2 +- internal/module/cloudflared.go | 44 ++-- internal/module/gpu.go | 48 ++-- internal/module/lifecycle.go | 24 +- internal/module/module.go | 26 +- internal/module/output_profiles_test.go | 227 ++++++++++++++++++ internal/module/output_writer_test.go | 204 ++++++++++++++++ internal/module/quota.go | 10 +- internal/module/run_context_output_test.go | 36 +++ internal/module/ssh.go | 3 +- internal/module/storage.go | 10 +- internal/module/tenant.go | 3 + .../testdata/output-t4/base.after.golden | 3 + .../testdata/output-t4/base.before.golden | 3 + .../testdata/output-t4/dgx.after.golden | 21 ++ .../testdata/output-t4/dgx.before.golden | 21 ++ .../testdata/output-t4/full.after.golden | 11 + .../testdata/output-t4/full.before.golden | 11 + .../output-t4/gpu-server.after.golden | 16 ++ .../output-t4/gpu-server.before.golden | 16 ++ .../testdata/output-t4/minimal.after.golden | 9 + .../testdata/output-t4/minimal.before.golden | 9 + .../testdata/output-t4/rocky.after.golden | 9 + .../testdata/output-t4/rocky.before.golden | 9 + internal/module/users.go | 73 +++--- internal/tenant/import.go | 6 +- internal/tenant/manager.go | 13 +- internal/tenant/volume.go | 4 +- internal/ui/format.go | 10 + internal/ui/output_boundary_test.go | 75 ++++++ internal/ui/text_writer_test.go | 17 ++ tests/scenarios/capture-output-routing.py | 90 +++++++ 75 files changed, 1641 insertions(+), 267 deletions(-) create mode 100644 internal/cli/json_escaping_test.go create mode 100644 internal/cli/testdata/output-t4/base.after.golden create mode 100644 internal/cli/testdata/output-t4/base.before.golden create mode 100644 internal/cli/testdata/output-t4/dgx.after.golden create mode 100644 internal/cli/testdata/output-t4/dgx.before.golden create mode 100644 internal/cli/testdata/output-t4/full.after.golden create mode 100644 internal/cli/testdata/output-t4/full.before.golden create mode 100644 internal/cli/testdata/output-t4/gpu-server.after.golden create mode 100644 internal/cli/testdata/output-t4/gpu-server.before.golden create mode 100644 internal/cli/testdata/output-t4/minimal.after.golden create mode 100644 internal/cli/testdata/output-t4/minimal.before.golden create mode 100644 internal/cli/testdata/output-t4/results.json create mode 100644 internal/cli/testdata/output-t4/rocky.after.golden create mode 100644 internal/cli/testdata/output-t4/rocky.before.golden create mode 100644 internal/cli/writer_routing_test.go create mode 100644 internal/module/output_profiles_test.go create mode 100644 internal/module/output_writer_test.go create mode 100644 internal/module/run_context_output_test.go create mode 100644 internal/module/testdata/output-t4/base.after.golden create mode 100644 internal/module/testdata/output-t4/base.before.golden create mode 100644 internal/module/testdata/output-t4/dgx.after.golden create mode 100644 internal/module/testdata/output-t4/dgx.before.golden create mode 100644 internal/module/testdata/output-t4/full.after.golden create mode 100644 internal/module/testdata/output-t4/full.before.golden create mode 100644 internal/module/testdata/output-t4/gpu-server.after.golden create mode 100644 internal/module/testdata/output-t4/gpu-server.before.golden create mode 100644 internal/module/testdata/output-t4/minimal.after.golden create mode 100644 internal/module/testdata/output-t4/minimal.before.golden create mode 100644 internal/module/testdata/output-t4/rocky.after.golden create mode 100644 internal/module/testdata/output-t4/rocky.before.golden create mode 100644 internal/ui/output_boundary_test.go create mode 100644 internal/ui/text_writer_test.go create mode 100644 tests/scenarios/capture-output-routing.py diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index d15c3a1..a3e803d 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -20,12 +20,12 @@ paths: - **Package layering** (non-test imports on this branch). Keep it acyclic and do not add upward edges: - `internal/ui`, `internal/config`, `internal/state` import no other internal package. - `internal/exec` imports `config` only. - - `internal/tenant` imports `config` and `exec`. It must not import `internal/module` (module imports tenant). + - `internal/tenant` imports `config`, `exec` and the leaf `ui` package. It must not import `internal/module` (module imports tenant). - `internal/module` imports `config`, `exec`, `state`, `tenant`, `ui`. - `internal/fleet` imports `config` and `module` (`internal/fleet/rollout.go` resolves the enabled modules of a site config through `module.NewRegistry`). - `internal/cli` is the only package that imports `fleet` and the only one `cmd/rootfiles` imports. - **Entry point** (`cmd/rootfiles/main.go`): `main` builds a context cancelled on SIGINT/SIGTERM and calls `cli.Execute`. An error that unwraps to `*cli.ExitError` exits with its `Code`, printing `Error: ` to stderr only when `Msg` is non-empty (drift exit 2 is silent). Any other error is printed as `Error: ` and exits 1. Never let an error exit without output. `version` and `commit` are set by goreleaser ldflags (`-X main.version`, `-X main.commit`; see [self-update](self-update.md)). -- **Output styling** (`internal/ui/styles.go`, `markers.go`, `format.go`): status-style reports go through `WriteHeader`, `WriteSection`, `WriteKV`, `WriteHint`, `WriteBullet` and the `OKMark`/`FailMark`/`PendingMark`/`WarnMark` helpers, writing to an `io.Writer`. lipgloss degrades to plain text when stdout is not a TTY or `NO_COLOR` is set; do not add manual colour gating. Use the `Mark*` constants instead of glyph literals. Several packages still print literal glyphs or raw `fmt` (`internal/module/users.go`, `ssh.go`, `module.go`, `internal/cli/rollback.go`, `upgrade.go`, `doctor.go`, `internal/tenant/manager.go` and others); that is known debt tracked in #94, not a pattern to copy. +- **Output styling** (`internal/ui/styles.go`, `markers.go`, `format.go`): status-style reports go through `WriteHeader`, `WriteSection`, `WriteKV`, `WriteHint`, `WriteBullet` and the `OKMark`/`FailMark`/`PendingMark`/`WarnMark` helpers, writing to an `io.Writer`. lipgloss degrades to plain text when stdout is not a TTY or `NO_COLOR` is set; do not add manual colour gating. Use the `Mark*` constants instead of glyph literals. Module and CLI text use an explicit command/context writer. Plain tables and payloads use `WriteText`, `WriteLine` or `WriteRaw` without extra styling; names-only output stays byte-exact. Glyph literals live only in `ui`; machine-facing descriptions use unstyled marker constants. Tenant output keeps its existing Manager.Out and marker bytes. - **Display fallback** (`ui.FirstNonEmpty`): returns the first nonempty string verbatim; shared by native status and cloudflared update text without trimming or formatting changes. - **Prompts** (`internal/ui/prompt.go`, Charm huh): every prompt helper takes `unattended` and returns its default (`Confirm` returns true) without reading the terminal when it is true. `--yes` must reach every prompt as `unattended`, so unattended runs never block. @@ -54,10 +54,8 @@ paths: ## Tests that pin it +- `internal/ui/output_boundary_test.go` guards against stdout-only fmt.Print calls in module/CLI code and glyph literals outside ui. Writer tests cover payload bytes and the RunContext nil fallback. + - `internal/ui/format_test.go`: header, section, KV and bullet layout with a forced plain colour profile. - Exit-code handling is exercised through the CLI tests listed in [cli-runtime](cli-runtime.md) and [engine](engine.md). - No test enforces the package layering; `go build` rejects import cycles only. - -## Open issues - -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94) route module and CLI output through `internal/ui` and an injected writer; remove literal glyphs outside `internal/ui`. diff --git a/docs/specs/cli-runtime.md b/docs/specs/cli-runtime.md index ded1d64..4706696 100644 --- a/docs/specs/cli-runtime.md +++ b/docs/specs/cli-runtime.md @@ -25,9 +25,10 @@ Shared plumbing every native `rootfiles` command runs through: the cobra root an - **Preflight order** (`preflight`, `requireRootAndLock`): reject ignored inherited inputs first, using reader declarations at the command that consumes them; non-annotated commands then pass; `--dry-run` passes without root or lock; otherwise euid must be 0, then the host OS must be Linux (`nativeHostOS`), then `state.Acquire()` takes the global non-blocking flock, then an audit `start` record is written. Root is checked before the OS so the error names the real cause. The lock is held until process exit (`heldLock`, released in `Execute`). - Commands that are read-only by default but mutate on a sub-path (`rollback `) call `requireRootAndLock` themselves at the point of mutation. - **Audit log** (`openAudit`, `auditf`): JSON lines via `slog` to `state.LogPath()` (default `/var/log/rootfiles.log`, mode 0640), each with `pid`, `version` and `cmd`. It is opened lazily and silently skipped when it cannot be opened (non-root). `newLogger` tees operator log lines into it only for real (non-dry-run) mutating runs. +- **Output writers**: CLI constructors and report callers put `cmd.OutOrStdout()` in `RunContext.Out`; module reports resolve it through `rc.Output()` with a stdout fallback for older callers. CLI text uses the command writer; console logging and apply-recording warnings use the error writer. Explicit metadata/audit stderr warnings remain separate from machine stdout. - **Runner** (`newRunner`): dry-run is passed through; a backup session (`exec.NewBackup(state.BackupsDir(), ...)`) is attached only for real mutating runs as root, so `rootfiles rollback` can undo them. See [state-rollback](state-rollback.md) and [exec-packages](exec-packages.md). - **Exit codes** (`ExitError`, `exitDrift` in `internal/cli/output.go`; printing in `cmd/rootfiles/main.go`): 0 success, 2 drift or findings on read-only reports, 1 any other error. An `ExitError` with an empty `Msg` exits silently. Never print an error inside a command and also return it; `SilenceErrors` leaves printing to `main`. -- **Output formats** (`addOutputFlag`, `addOutputFlagWithMetrics`, `outputFormat`): `-o text|json`; `prometheus` is accepted only on commands annotated `rootfiles/prometheus`. Unknown values are an error. Backup destinations use `backup --dir` and `user backup --file`; their old `-o/--output` spellings remain deprecated path aliases for one minor release. Canonical path flags win when both are supplied. JSON goes through `writeJSON` (indented, no HTML escaping). +- **Output formats** (`addOutputFlag`, `addOutputFlagWithMetrics`, `outputFormat`): `-o text|json`; `prometheus` is accepted only on commands annotated `rootfiles/prometheus`. Unknown values are an error. Backup destinations use `backup --dir` and `user backup --file`; their old `-o/--output` spellings remain deprecated path aliases for one minor release. Canonical path flags win when both are supplied. Native JSON goes through `writeJSON` (indented, no HTML escaping). Fleet results share the encoding implementation with their existing HTML escaping enabled; field order, indentation and final newline are unchanged. Shared `exitDrift` retains controller exit code 2. - **Target resolution for reports** (`resolveTarget`, used by check, status, doctor and config show/validate): `--profile`/`ROOTFILES_PROFILE` or `--config` first; then the applied copy `state.AppliedConfigPath()` only when `reusableConfig` (root-owned, not group/other-writable, regular file, every ancestor root-only via `config.RootOnlyBase`, readable) and `sameRun` (its SHA-256 equals the last run's `ConfigSHA256`) both pass; then an absolute recorded config path that still exists; then the recorded profile; then `SuggestProfile()`. - **Target resolution for root-mutating subcommands** (`resolveRunTarget`, used by user, gpu, tunnel, schedule, tenant and backup through `buildRunContext`/`buildConfigFreeRunContext` in `internal/cli/tunnel.go`; the config-free variant falls back to `config.Fallback` on a load error): flags/env first; then the applied copy under the same two checks; otherwise `minimal` with home-base detection. They never re-read a recorded path, re-resolve a recorded profile or take the detected suggestion. A dry run of a mutating command that cannot reuse the copy warns. This is the AGENTS.md rule; `buildRunContext` lives in the [cloudflared](cloudflared.md) spec's file but the contract is owned here. - **Reported target** (`reportedTarget`): when the applied copy was used, reports name the recorded run's profile and path, never the copy's path. @@ -66,7 +67,3 @@ Shared plumbing every native `rootfiles` command runs through: the cobra root an - `internal/cli/check_test.go`: `TestCheck_RejectsUnknownOutput`. - `internal/cli/docs_test.go`: `TestDocsCommandsCoverCobraTree` (every visible command has a `### ` heading in `docs/commands.md` that names its visible local flags). - `tests/scenarios/state-rollback-lock.sh` (CI job `scenario`): real refusal without root and while another command holds the lock. - -## Open issues - -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining output through `internal/ui` and injected writers. Capability/context/masking helpers are shared. diff --git a/docs/specs/cloudflared.md b/docs/specs/cloudflared.md index a8ad91b..8a29975 100644 --- a/docs/specs/cloudflared.md +++ b/docs/specs/cloudflared.md @@ -32,6 +32,8 @@ The `cloudflared` module and `rootfiles tunnel` install the upstream `cloudflare ## Behaviour +- Native tunnel text uses the run-context writer; update-check and CLI confirmation text use the command writer. Machine-facing download commands retain the unstyled arrow byte. Writer routing changes no query, download, reload or restart. + - `InspectCloudflared` owns the production binary path and performs one read-only version query. It returns presence, raw stdout, parsed version and query error separately; status keeps raw output while update comparisons keep their existing parsed/normalized policies. CLI path variables are test overrides only. - Rootfiles and cloudflared latest-tag lookups share `FetchLatestGitHubTag`; wrappers keep their existing clients, deadlines, raw tag strings and error wording. Cloudflared retains its 15-second timeout. `ui.FirstNonEmpty` shares display fallback without trimming. @@ -59,7 +61,3 @@ The `cloudflared` module and `rootfiles tunnel` install the upstream `cloudflare - `internal/module/cloudflared_test.go`: token service convergence, private-network address drift, bad token rejection, token file accepted/rejected (symlink, mode, owner), explicit token over file. - `internal/cli/tunnel_test.go`: subcommand tree and the run-context loaders (contract owned by [cli-runtime](cli-runtime.md)). - Scenario `tests/scenarios/tunnel-setup-teardown.sh` (CI `scenario` job). - -## Open issues - -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining output through `ui` and injected writers; path/version observation, latest-tag lookup and display fallback are shared. diff --git a/docs/specs/docker-nvidia.md b/docs/specs/docker-nvidia.md index e53a1e7..3d95a55 100644 --- a/docs/specs/docker-nvidia.md +++ b/docs/specs/docker-nvidia.md @@ -55,7 +55,3 @@ The `docker` module installs Docker CE with the compose and buildx plugins and m - `internal/module/agreement_test.go`: complete Docker/NVIDIA installation package lists, pending/current/failed Docker service work, and continued Rocky capability rejection. - `internal/module/nvidia_test.go`: `TestNvidiaRuntimeRegistrationReloadsDocker`. - No CI `module` matrix entry exists for `docker` or `nvidia`; they run only under the `unit` job and in profile dry-runs (`tests/scenarios/dry-run-all-profiles.sh`). - -## Open issues - -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): shared write-if-changed and `systemdReload` helpers. diff --git a/docs/specs/doctor-schedule.md b/docs/specs/doctor-schedule.md index 3c42028..d471047 100644 --- a/docs/specs/doctor-schedule.md +++ b/docs/specs/doctor-schedule.md @@ -41,6 +41,8 @@ paths: ## Behaviour +- Doctor passes the command writer into its run context and uses the shared marker constant for hints. JSON/Prometheus goldens and schedule command/unit bytes stay unchanged. + - Schedule enable writes only units with real content changes, preserving old headers. It runs daemon-reload only after an actual unit write; already enabled/active timers skip enable; a necessary enable uses `--no-reload` to prevent an implicit daemon-reload. First-report behavior is unchanged. - `rootfiles schedule status` reads the timer file and read-only systemctl state, showing installation, enabled/active status, OnCalendar and report ages (stale after 48 hours). It loads no profile, detects no distro, takes no root lock and refuses ignored profile/config/home-base inputs. @@ -72,4 +74,3 @@ paths: ## Open issues - [#91](https://github.com/STAIxBWLB/rootfiles/issues/91): `doctor` ignores `--module`; reject it. -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining UI/writer routing; capability validation and read-only context construction are shared without changing report findings or schedule command bytes. diff --git a/docs/specs/engine.md b/docs/specs/engine.md index 219446f..b22171e 100644 --- a/docs/specs/engine.md +++ b/docs/specs/engine.md @@ -56,12 +56,14 @@ The Module interface, the registry and execution order, and the three commands t - Check must stay side-effect free: no writes, no installs, no metadata refresh; Runner writes are dry-run gated but `Runner.Query` is not, so a Check must only query. - `ensureMetaDir` refuses a legacy `/.rootfiles` file without moving it, including in dry run, and names the manual relocation to `.rootfiles/users.json`. Shared JSON ledger reads distinguish absence from corruption; atomic sibling writes and destination replacement go through Runner so dry-run and backups apply. -- Status lines in `RunAll` are printed to stdout with literal glyphs, not through `internal/ui` (see #94). +- `RunContext.Out` receives command text through `Output()`; nil retains stdout for existing callers. `RunAll` status rows use shared UI bullets (two spaces after the marker); outcome statuses, service calls and dry-run policy are unchanged. Text-only routing never decorates machine payloads. - Not planned (retired 2026-11-10, from the 2026-09 improvement plan): a common disk-space and network-reachability preflight. `doctor` already reports disk use against thresholds, and package or download failures already stop with their error. A refusal on low disk would block `apply` on converged hosts whose root filesystem is close to full, which contract #115 forbids. ## Tests that pin it +- `internal/module/output_profiles_test.go` loads all six real profiles and runs actual Check/RunAll with private paths and QueryGuard; pending/satisfied/blocked outcomes and zero filesystem/backup mutation are checked against reviewed before/after text captures. `tests/scenarios/capture-output-routing.py` separately captures full CLI previews in a disposable dev container using the existing detector/path seams; all six profiles reach RunAll and exit 0, with identical stderr except query-log clocks and only marker spacing changed. + - `internal/cli/report_machine_contract_test.go` pins pre-refactor check/doctor JSON and Prometheus bytes, field/metric ordering, HTML/quote/newline escaping, empty check modules as null, and exit codes. A test clock and synthetic doctor findings keep captures deterministic without host reads. - `internal/cli/status_collector_contract_test.go` pins eleven JSON and ten text baselines captured before the refactor; only fixture-root/hostname are normalized. It covers raw version/HTML escaping, omitted/empty fields, filters, fallback, unknown home and failed probes. `status_snapshot_test.go` changes fixture files after collection and proves text displays the snapshot without more queries. Unsupported capability text is the named reconciliation with existing JSON behavior. @@ -77,4 +79,3 @@ The Module interface, the registry and execution order, and the three commands t ## Open issues - [#91](https://github.com/STAIxBWLB/rootfiles/issues/91): `status` honours `--module`; `ROOTFILES_YES` read in one helper. -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining module output through `internal/ui` and `rc.Out`; status text/JSON already share one collector. diff --git a/docs/specs/fleet.md b/docs/specs/fleet.md index 25f36f2..3918419 100644 --- a/docs/specs/fleet.md +++ b/docs/specs/fleet.md @@ -50,6 +50,8 @@ Invariants an agent must keep: ## Behaviour +- JSON results use the shared CLI encoder with HTML escaping enabled, retaining the previous bytes. Native reports keep HTML escaping disabled. Controller findings use shared exitDrift (still 2); remote command lines are unchanged. + - `fleet targets` honors explicit host/group/all selectors; an omitted selector retains whole-inventory rendering. Explicit empty/unmatched selectors never fall back to all hosts. - `fleet schedule enable --on-calendar EXPR` passes a validated, shell-quoted expression; omitted calendar keeps the prior remote enable/disable command bytes. Disable refuses a calendar. Read-only/native report and apply/update wire flags remain accepted. - Fleet dry-run skips the real-rollout yes requirement but retains explicit rollout selection; real mutations still require confirmation. @@ -114,4 +116,3 @@ Verification evidence (folded from the 2026-10-03 rollout checklist): ## Open issues - [#91](https://github.com/STAIxBWLB/rootfiles/issues/91): fleet commands accept and ignore `--module`, `--profile`, `--config`, `--force`, `--tunnel-token`; `-o` is validated only after the SSH work; sudoers and remote command strings must stay unchanged. -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining JSON encoder and hardcoded exit 2. Quoting is shared by `fleet.ShellQuote`; run logs intentionally use XDG state storage while inventory uses XDG config storage. diff --git a/docs/specs/gpu-allocation.md b/docs/specs/gpu-allocation.md index fe316fc..730e944 100644 --- a/docs/specs/gpu-allocation.md +++ b/docs/specs/gpu-allocation.md @@ -38,6 +38,8 @@ Per-user GPU assignment on shared GPU hosts. One ledger records which physical G ## Behaviour +- Allocation/status tables and assignment/revoke messages use the context writer. Existing columns, spacing and payload bytes are retained; output routing introduces no GPU or service operation. + - `rootfiles gpu assign USER --gpus 0,1 [--method env|cgroup|both]`, `gpu revoke USER` (both in `mutatingCommands`), `gpu list`, `gpu status` (all physical GPUs with `nvidia-smi` data where visible, `(restricted)` otherwise), `gpu mig status`. - `parseGPUList` requires at least one non-negative integer index. - Assign and revoke apply their method side effects immediately; `apply --module gpu` re-converges every user entry. @@ -65,4 +67,3 @@ Per-user GPU assignment on shared GPU hosts. One ledger records which physical G ## Open issues - [#91](https://github.com/STAIxBWLB/rootfiles/issues/91): `gpu assign`/`revoke` skip capability validation and always build an APT backend. -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining output through `ui`; equivalent home-base defaults use `UsersConfig.Base()`, while unknown-base guards remain separate. Shared ordinary-file/systemd helpers are in place. diff --git a/docs/specs/host-base.md b/docs/specs/host-base.md index f389676..957864f 100644 --- a/docs/specs/host-base.md +++ b/docs/specs/host-base.md @@ -63,7 +63,3 @@ paths: - `internal/module/metadata_test.go`: `TestMetadataCommandsRefuseUntrustedBase` covers storage Check refusing an untrusted base. - CI `module` job: `locale`, `system`, `storage` on `ubuntu-22.04` and `ubuntu-24.04` (`tests/integration/run-module.sh`); CI `rocky` job for Rocky locale and journald. - `tests/scenarios/os-reinstall-recovery.sh` applies `storage` with the `dgx` profile. - -## Open issues - -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining repeated home-base defaults and output routing; ordinary sysctl/journald/APT-mirror writes already use the shared helper. diff --git a/docs/specs/self-update.md b/docs/specs/self-update.md index abfd4b3..a0ca599 100644 --- a/docs/specs/self-update.md +++ b/docs/specs/self-update.md @@ -56,6 +56,8 @@ Release notes (`scripts/check-release-notes.sh`) ## Behaviour +- Update progress/check text uses the command writer through UI plain-text helpers. Exit codes, error printing, download/replace behavior and message bytes are unchanged. + - Test workflow input prefixes refer to existing source/build/test paths; stale root-level `profiles/` and `templates/` prefixes are absent. Documentation inputs added by the docs workflow remain independently tracked. - `rootfiles update [--check] [--version vX.Y.Z] [--dry-run]`: prints current and target, downloads, verifies, extracts the regular file named `rootfiles` from the archive, replaces, ensures the alias, then reports apply-pending. `--check` prints whether an update exists and exits 0 either way. @@ -82,4 +84,3 @@ Release notes (`scripts/check-release-notes.sh`) ## Open issues - [#87](https://github.com/STAIxBWLB/rootfiles/issues/87): Go toolchain and action pinning, required checks (workflows use `go-version: stable` and `govulncheck@latest`). -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining `upgrade.go` output through `ui` and `cmd.OutOrStdout()`; latest-tag lookup and version parsing/observation are shared without changing comparisons. diff --git a/docs/specs/ssh-access.md b/docs/specs/ssh-access.md index aacb884..8ccf2ab 100644 --- a/docs/specs/ssh-access.md +++ b/docs/specs/ssh-access.md @@ -70,7 +70,3 @@ paths: - `internal/module/doctor_test.go`: `TestDoctorSSHWarnsPasswordOnlyAccounts` (shared access helpers). - `tests/scenarios/ssh-lockout-guard.sh` (CI `scenario` job): refusal without key logins, hardening with a key-holding operator, password-only accounts, UFW admits port 22, idempotent second run. - CI `module` job: `ssh` and `network` on `ubuntu-22.04` and `ubuntu-24.04` via `tests/integration/run-module.sh`. - -## Open issues - -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): `ssh.go` prints the `--force` warning to stdout directly; daemon-reload failure policy to unify. diff --git a/docs/specs/state-rollback.md b/docs/specs/state-rollback.md index 75473a0..e092bbb 100644 --- a/docs/specs/state-rollback.md +++ b/docs/specs/state-rollback.md @@ -31,6 +31,8 @@ What rootfiles remembers on a host and how an operator undoes it: the recorded a ## Behaviour +- Backup/rollback text uses the command writer. Rollback symlink previews use the ASCII ` -> ` separator; restore actions and manifest fields are unchanged. + - `rootfiles rollback [BACKUP_ID] [--dry-run] [--yes]`. - `rootfiles backup [--dir DIR] [--skip-docker] [--skip-etc] [--dry-run]`: all writes go through the Runner, so a dry run writes nothing. Restore is manual: `rootfiles apply --config /config-snapshot.yaml`, plus `rootfiles user restore` for accounts. @@ -56,4 +58,3 @@ What rootfiles remembers on a host and how an operator undoes it: the recorded a - [#88](https://github.com/STAIxBWLB/rootfiles/issues/88): redact secrets from the backup-session note that `rollback` prints. - [#91](https://github.com/STAIxBWLB/rootfiles/issues/91): `backup` becomes a mutating command (lock and audit); `rollback` honours `ROOTFILES_YES`. -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): backup prints through `internal/ui` and `cmd.OutOrStdout()`. Tar scripts already use shared `fleet.ShellQuote`, preserving their command bytes. diff --git a/docs/specs/tenant.md b/docs/specs/tenant.md index 76fcdff..48824f8 100644 --- a/docs/specs/tenant.md +++ b/docs/specs/tenant.md @@ -86,6 +86,8 @@ Single-port SSH (sshpiper) ## Behaviour +- CLI-created managers keep the command writer. NewTenantManager honors an explicit writer (including io.Discard); a nil argument inherits rc.Output. The tenant package uses UI marker constants without changing its existing text, data or command strings. + - `tenant passwd` is the single password command; `reset` resolves as its alias with the same mutation preflight. Help shows one password command and lists positional arguments separately from flags. - Module Check (`tenantChanges`) lists: managed files that differ (guard script `/usr/local/sbin/rootfiles-gpu-guard`, guard and sync units and timers), timers not enabled (`rootfiles-gpu-guard.timer` every minute, `rootfiles-tenant-sync.timer` every 2 minutes, `fstrim.timer`), missing operator group, sudoers rule content or file mode drift, stale or disabled mount units of existing tenants, and sshpiper changes. Apply does exactly these, reloads systemd once when a unit changed (reload failure is a warning), and reports `Changed=false` in dry-run. @@ -118,5 +120,4 @@ Single-port SSH (sshpiper) ## Open issues -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining output routing. Password generation is shared through `tenant.GeneratePassword`; its alphabet, lengths and stdin-only delivery stay unchanged. Host prep uses shared ordinary-file/systemd helpers; tenant runtime atomic writeIfChanged remains a distinct concern. - Not tracked: the 2026-10-20 design stored GPU UUIDs and checked OCI `linux.devices`; the code stores indices and verifies with `nvidia-smi -L`. diff --git a/docs/specs/users.md b/docs/specs/users.md index de2ccae..2da1479 100644 --- a/docs/specs/users.md +++ b/docs/specs/users.md @@ -41,6 +41,8 @@ The `users` module and the `rootfiles user` command group manage human accounts ## Behaviour +- User reports and lifecycle/quota text use the context writer. Names-only lists and raw id/groups/quota tool output retain their bytes, including percent signs and missing final newlines; metadata trust warnings stay on warnOut/stderr. + - New metadata databases use `CreatedBy: rootfiles`. Existing databases retain their recorded value, including `rootfiles-v2`; marker cleanup alone never rewrites a ledger. - `user add` uses the inherited `--ssh-pubkey`; deprecated `--pubkey` remains an alias, including username fallback via `--user` or environment. Prefix root flags retain their values. `user backup --file` keeps deprecated `-o/--output` aliases with a replacement notice. @@ -74,4 +76,3 @@ The `users` module and the `rootfiles user` command group manage human accounts ## Open issues - [#88](https://github.com/STAIxBWLB/rootfiles/issues/88): `user passwd --password` lands in the audit log and backup note. -- [#94](https://github.com/STAIxBWLB/rootfiles/issues/94): remaining direct stdout prints in `users.go`, `lifecycle.go`, `quota.go`. diff --git a/internal/cli/apply.go b/internal/cli/apply.go index 7cf1158..994837a 100644 --- a/internal/cli/apply.go +++ b/internal/cli/apply.go @@ -3,6 +3,7 @@ package cli import ( "errors" "fmt" + "io" "os" "time" @@ -33,6 +34,7 @@ func newApplyCmd() *cobra.Command { func runApply(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() + out := cmd.OutOrStdout() yes := yesFlag(cmd) dryRun, _ := cmd.Flags().GetBool("dry-run") @@ -82,28 +84,28 @@ func runApply(cmd *cobra.Command, _ []string) error { modules := registry.Resolve(cfg, moduleFilter) if len(modules) == 0 { - fmt.Println("No modules to apply.") + ui.WriteLine(out, "No modules to apply.") return nil } // Show plan - fmt.Printf("Profile: %s\n", profileName) + ui.WriteText(out, "Profile: %s\n", profileName) if dryRun { - fmt.Println("Mode: dry-run (no changes will be made)") + ui.WriteLine(out, "Mode: dry-run (no changes will be made)") } - fmt.Printf("Modules: ") + ui.WriteText(out, "Modules: ") for i, m := range modules { if i > 0 { - fmt.Print(", ") + ui.WriteRaw(out, ", ") } - fmt.Print(m.Name()) + ui.WriteRaw(out, m.Name()) } - fmt.Println() + ui.WriteLine(out) // Interactive configuration preview & edit - if err := configureInteractive(cfg, yes, dryRun); err != nil { + if err := configureInteractive(out, cfg, yes, dryRun); err != nil { if errors.Is(err, errAborted) { - fmt.Println("Aborted.") + ui.WriteLine(out, "Aborted.") return nil } return err @@ -124,6 +126,7 @@ func runApply(cmd *cobra.Command, _ []string) error { // Execute modules rc := &module.RunContext{ Config: cfg, + Out: out, Runner: runner, APT: apt, DryRun: dryRun, @@ -134,7 +137,7 @@ func runApply(cmd *cobra.Command, _ []string) error { if err := module.Preflight(ctx, modules, rc); err != nil { return err } - fmt.Println() + ui.WriteLine(out) started := time.Now().UTC() outcomes, runErr := module.RunAll(ctx, modules, rc) if dryRun { @@ -153,15 +156,15 @@ func runApply(cmd *cobra.Command, _ []string) error { } if runner.Backup.Used() { run.BackupID = runner.Backup.ID - fmt.Printf("\nFiles changed by this run were backed up (undo with: rootfiles rollback %s)\n", run.BackupID) + ui.WriteText(out, "\nFiles changed by this run were backed up (undo with: rootfiles rollback %s)\n", run.BackupID) } if err := state.Record(run); err != nil { - fmt.Fprintf(os.Stderr, "warning: recording apply state: %v\n", err) + ui.WriteText(cmd.ErrOrStderr(), "warning: recording apply state: %v\n", err) } // Keep the copy in step with the recorded run, failed or not, as check // follows the recorded run; an outdated copy must not outlive a failed save. if err := saveAppliedConfig(cfg); err != nil { - fmt.Fprintf(os.Stderr, "warning: saving the applied config: %v\n", err) + ui.WriteText(cmd.ErrOrStderr(), "warning: saving the applied config: %v\n", err) _ = state.SaveAppliedConfig(nil) } auditf(cmd, "apply finished", "profile", profileName, "success", runErr == nil, "backup", run.BackupID) @@ -246,12 +249,12 @@ func applyAccountFlags(cmd *cobra.Command, cfg *config.Config) { // configureInteractive shows a preview of all key settings and lets the user // review/modify them before applying. Skipped when --yes is set. -func configureInteractive(cfg *config.Config, yes, dryRun bool) error { +func configureInteractive(out io.Writer, cfg *config.Config, yes, dryRun bool) error { if yes { return nil } - fmt.Println("\n=== Configuration ===") + ui.WriteLine(out, "\n=== Configuration ===") // Snapshot the profile values so the summary can flag any setting the // operator made less secure than the profile intends. @@ -267,7 +270,7 @@ func configureInteractive(cfg *config.Config, yes, dryRun bool) error { // --- SSH --- if cfg.IsModuleEnabled("ssh") { - fmt.Println("\n--- SSH ---") + ui.WriteLine(out, "\n--- SSH ---") cfg.SSH.DisableRootLogin, err = ui.ConfirmDefault("Disable root login?", cfg.SSH.DisableRootLogin, false) if err != nil { return err @@ -291,7 +294,7 @@ func configureInteractive(cfg *config.Config, yes, dryRun bool) error { // --- Users --- if cfg.IsModuleEnabled("users") { - fmt.Println("\n--- Users ---") + ui.WriteLine(out, "\n--- Users ---") if cfg.Users.HomeBase != "" { cfg.Users.HomeBase, err = ui.Input("Home base directory", cfg.Users.HomeBase, false) if err != nil { @@ -306,7 +309,7 @@ func configureInteractive(cfg *config.Config, yes, dryRun bool) error { // --- Docker --- if cfg.IsModuleEnabled("docker") { - fmt.Println("\n--- Docker ---") + ui.WriteLine(out, "\n--- Docker ---") if cfg.Modules.Docker.StorageDir != "" { cfg.Modules.Docker.StorageDir, err = ui.Input("Docker storage directory", cfg.Modules.Docker.StorageDir, false) if err != nil { @@ -317,9 +320,9 @@ func configureInteractive(cfg *config.Config, yes, dryRun bool) error { // --- Cloudflared --- if cfg.IsModuleEnabled("cloudflared") { - fmt.Println("\n--- Cloudflared ---") + ui.WriteLine(out, "\n--- Cloudflared ---") if f := cfg.Modules.Cloudflared.TunnelTokenFile; f != "" && cfg.Modules.Cloudflared.TunnelToken == "" { - fmt.Printf(" Tunnel token: read from %s\n", f) + ui.WriteText(out, " Tunnel token: read from %s\n", f) } else { cfg.Modules.Cloudflared.TunnelToken, err = ui.Input("Tunnel token (empty to skip)", cfg.Modules.Cloudflared.TunnelToken, false) if err != nil { @@ -340,7 +343,7 @@ func configureInteractive(cfg *config.Config, yes, dryRun bool) error { // --- Network --- if cfg.IsModuleEnabled("network") { - fmt.Println("\n--- Network ---") + ui.WriteLine(out, "\n--- Network ---") cfg.Modules.Network.UFW, err = ui.ConfirmDefault("Enable UFW firewall?", cfg.Modules.Network.UFW, false) if err != nil { return err @@ -355,7 +358,7 @@ func configureInteractive(cfg *config.Config, yes, dryRun bool) error { // --- Storage --- if cfg.IsModuleEnabled("storage") { - fmt.Println("\n--- Storage ---") + ui.WriteLine(out, "\n--- Storage ---") if cfg.Modules.Storage.DataDir != "" { cfg.Modules.Storage.DataDir, err = ui.Input("Data directory", cfg.Modules.Storage.DataDir, false) if err != nil { @@ -365,11 +368,11 @@ func configureInteractive(cfg *config.Config, yes, dryRun bool) error { } // --- Summary & confirm --- - printApplySummary(cfg) + printApplySummary(out, cfg) downgrades := securityDowngrades(&profile, cfg) for _, d := range downgrades { - fmt.Println(" " + ui.StyleWarning.Render(ui.MarkWarn+" weaker than profile: "+d)) + ui.WriteLine(out, " "+ui.StyleWarning.Render(ui.MarkWarn+" weaker than profile: "+d)) } if dryRun { @@ -407,34 +410,34 @@ func securityDowngrades(before, after *config.Config) []string { // printApplySummary is kept separate from prompts so its secret handling can // be checked without an interactive terminal. -func printApplySummary(cfg *config.Config) { - fmt.Println("\n=== Summary ===") - fmt.Printf(" Timezone: %s\n", cfg.Timezone) +func printApplySummary(out io.Writer, cfg *config.Config) { + ui.WriteLine(out, "\n=== Summary ===") + ui.WriteText(out, " Timezone: %s\n", cfg.Timezone) if cfg.IsModuleEnabled("ssh") { - fmt.Printf(" SSH: root_login=%v, password_auth=%v, port=%d\n", + ui.WriteText(out, " SSH: root_login=%v, password_auth=%v, port=%d\n", !cfg.SSH.DisableRootLogin, !cfg.SSH.DisablePasswordAuth, cfg.SSH.Port) } if cfg.IsModuleEnabled("users") && cfg.Users.HomeBase != "" { - fmt.Printf(" Users: home_base=%s, sudo_nopasswd=%v\n", cfg.Users.HomeBase, cfg.Users.SudoNopasswd) + ui.WriteText(out, " Users: home_base=%s, sudo_nopasswd=%v\n", cfg.Users.HomeBase, cfg.Users.SudoNopasswd) } if cfg.IsModuleEnabled("docker") && cfg.Modules.Docker.StorageDir != "" { - fmt.Printf(" Docker: storage=%s\n", cfg.Modules.Docker.StorageDir) + ui.WriteText(out, " Docker: storage=%s\n", cfg.Modules.Docker.StorageDir) } if cfg.IsModuleEnabled("cloudflared") { token := maskSecret(cfg.Modules.Cloudflared.TunnelToken) if token == "" && cfg.Modules.Cloudflared.TunnelTokenFile != "" { token = "file:" + cfg.Modules.Cloudflared.TunnelTokenFile } - fmt.Printf(" Cloudflared: token=%s, vlan=%v", token, cfg.Modules.Cloudflared.PrivateNetwork.Enabled) + ui.WriteText(out, " Cloudflared: token=%s, vlan=%v", token, cfg.Modules.Cloudflared.PrivateNetwork.Enabled) if cfg.Modules.Cloudflared.PrivateNetwork.Enabled { - fmt.Printf(" (%s)", cfg.Modules.Cloudflared.PrivateNetwork.Address) + ui.WriteText(out, " (%s)", cfg.Modules.Cloudflared.PrivateNetwork.Address) } - fmt.Println() + ui.WriteLine(out) } if cfg.IsModuleEnabled("network") { - fmt.Printf(" Network: ufw=%v, ports=%v\n", cfg.Modules.Network.UFW, cfg.Modules.Network.AllowedPorts) + ui.WriteText(out, " Network: ufw=%v, ports=%v\n", cfg.Modules.Network.UFW, cfg.Modules.Network.AllowedPorts) } if cfg.IsModuleEnabled("storage") && cfg.Modules.Storage.DataDir != "" { - fmt.Printf(" Storage: data_dir=%s\n", cfg.Modules.Storage.DataDir) + ui.WriteText(out, " Storage: data_dir=%s\n", cfg.Modules.Storage.DataDir) } } diff --git a/internal/cli/backup.go b/internal/cli/backup.go index 059b845..ae79a51 100644 --- a/internal/cli/backup.go +++ b/internal/cli/backup.go @@ -14,6 +14,7 @@ import ( "github.com/STAIxBWLB/rootfiles/internal/config" "github.com/STAIxBWLB/rootfiles/internal/module" "github.com/STAIxBWLB/rootfiles/internal/state" + "github.com/STAIxBWLB/rootfiles/internal/ui" "github.com/STAIxBWLB/rootfiles/internal/fleet" ) @@ -43,6 +44,7 @@ func newBackupCmd() *cobra.Command { Short: "Full system backup for OS upgrade / restore", Long: "Captures system info, user metadata, config files, and a rootfiles-compatible config snapshot.", RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() outputBase := stringFlagOrAlias(cmd, "dir", "output") skipDocker, _ := cmd.Flags().GetBool("skip-docker") skipEtc, _ := cmd.Flags().GetBool("skip-etc") @@ -76,84 +78,84 @@ func newBackupCmd() *cobra.Command { if _, err := rc.Runner.Run(ctx, "chmod", "0700", backupDir); err != nil { return fmt.Errorf("securing backup directory: %w", err) } - fmt.Printf("Backup directory: %s\n\n", backupDir) + ui.WriteText(out, "Backup directory: %s\n\n", backupDir) var errors []string // 1. system-info.json - fmt.Print(" system-info.json ... ") + ui.WriteRaw(out, " system-info.json ... ") if err := backupSystemInfo(rc, backupDir, hostname); err != nil { errors = append(errors, fmt.Sprintf("system-info: %v", err)) - fmt.Println("FAIL") + ui.WriteLine(out, "FAIL") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } // 2. users.json - fmt.Print(" users.json ... ") + ui.WriteRaw(out, " users.json ... ") if err := backupUsersJSON(rc, backupDir); err != nil { errors = append(errors, fmt.Sprintf("users: %v", err)) - fmt.Println("SKIP (no user database)") + ui.WriteLine(out, "SKIP (no user database)") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } // 3. etc-config.tar.gz if !skipEtc { - fmt.Print(" etc-config.tar.gz ... ") + ui.WriteRaw(out, " etc-config.tar.gz ... ") if err := backupEtcConfig(ctx, rc, backupDir); err != nil { errors = append(errors, fmt.Sprintf("etc-config: %v", err)) - fmt.Println("FAIL") + ui.WriteLine(out, "FAIL") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } } // 4. crontab-root.txt - fmt.Print(" crontab-root.txt ... ") + ui.WriteRaw(out, " crontab-root.txt ... ") if err := backupCrontab(ctx, rc, backupDir); err != nil { errors = append(errors, fmt.Sprintf("crontab: %v", err)) - fmt.Println("SKIP (no crontab)") + ui.WriteLine(out, "SKIP (no crontab)") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } // 5. root-ssh.tar.gz - fmt.Print(" root-ssh.tar.gz ... ") + ui.WriteRaw(out, " root-ssh.tar.gz ... ") if err := backupRootSSH(ctx, rc, backupDir); err != nil { errors = append(errors, fmt.Sprintf("root-ssh: %v", err)) - fmt.Println("SKIP") + ui.WriteLine(out, "SKIP") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } // 6. usr-local-bin.tar.gz - fmt.Print(" usr-local-bin.tar.gz ... ") + ui.WriteRaw(out, " usr-local-bin.tar.gz ... ") if err := backupUsrLocalBin(ctx, rc, backupDir); err != nil { errors = append(errors, fmt.Sprintf("usr-local-bin: %v", err)) - fmt.Println("FAIL") + ui.WriteLine(out, "FAIL") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } // 7. docker-images.txt if !skipDocker { - fmt.Print(" docker-images.txt ... ") + ui.WriteRaw(out, " docker-images.txt ... ") if err := backupDockerImages(ctx, rc, backupDir); err != nil { errors = append(errors, fmt.Sprintf("docker-images: %v", err)) - fmt.Println("SKIP (docker not available)") + ui.WriteLine(out, "SKIP (docker not available)") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } } // 8. config-snapshot.yaml - fmt.Print(" config-snapshot.yaml ... ") + ui.WriteRaw(out, " config-snapshot.yaml ... ") if err := backupConfigSnapshot(rc, backupDir); err != nil { errors = append(errors, fmt.Sprintf("config-snapshot: %v", err)) - fmt.Println("FAIL") + ui.WriteLine(out, "FAIL") } else { - fmt.Println("OK") + ui.WriteLine(out, "OK") } // A same-day re-run overwrites existing files, and overwriting @@ -165,16 +167,16 @@ func newBackupCmd() *cobra.Command { } } - fmt.Println() + ui.WriteLine(out) if len(errors) > 0 { - fmt.Printf("Backup completed with %d warning(s):\n", len(errors)) + ui.WriteText(out, "Backup completed with %d warning(s):\n", len(errors)) for _, e := range errors { - fmt.Printf(" - %s\n", e) + ui.WriteText(out, " - %s\n", e) } } else { - fmt.Println("Backup completed successfully.") + ui.WriteLine(out, "Backup completed successfully.") } - fmt.Printf("\nRestore config with:\n rootfiles apply --config %s/config-snapshot.yaml --dry-run\n", backupDir) + ui.WriteText(out, "\nRestore config with:\n rootfiles apply --config %s/config-snapshot.yaml --dry-run\n", backupDir) return nil }, } diff --git a/internal/cli/check.go b/internal/cli/check.go index 14750fc..ea279b6 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "log/slog" - "os" "time" "github.com/spf13/cobra" @@ -60,10 +59,11 @@ func runCheck(cmd *cobra.Command, _ []string) error { return err } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + logger := slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{ Level: slog.LevelWarn, })) rc := readOnlyRunContext(cfg, sysInfo, logger) + rc.Out = cmd.OutOrStdout() // Check is a strict evaluator, unlike overview reports which retain a // backend for diagnostic output on unsupported operating systems. rc.APT, err = exec.NewPackageManager(rc.Runner, sysInfo) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 452130f..cea79e4 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -49,6 +49,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error { } rc := readOnlyRunContext(cfg, sys, slog.New(slog.NewTextHandler(io.Discard, nil))) + rc.Out = cmd.OutOrStdout() findings := doctorChecks(cmd.Context(), rc) if loadErr != nil { findings = append(findings, module.Finding{Check: "configuration", Level: module.LevelFail, Detail: loadErr.Error()}) @@ -109,7 +110,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error { } ui.WriteBullet(out, marker, fmt.Sprintf("%-16s %s", f.Check, f.Detail)) if f.Hint != "" && f.Level != module.LevelOK { - fmt.Fprintf(out, " %s\n", ui.StyleHint.Render("→ "+f.Hint)) + fmt.Fprintf(out, " %s\n", ui.StyleHint.Render(ui.MarkPending+" "+f.Hint)) } } fmt.Fprintln(out) diff --git a/internal/cli/fleet.go b/internal/cli/fleet.go index c29dccc..f39979e 100644 --- a/internal/cli/fleet.go +++ b/internal/cli/fleet.go @@ -2,7 +2,6 @@ package cli import ( "context" - "encoding/json" "fmt" "io" "os" @@ -117,7 +116,7 @@ func newFleetReadCmd(op string) *cobra.Command { return err } if !allOK(results) { - return &ExitError{Code: 2} + return &ExitError{Code: exitDrift} } return nil }} @@ -150,9 +149,7 @@ func runReadPool(ctx context.Context, r fleet.SSHRunner, hosts []fleet.NamedHost func writeFleetResults(w io.Writer, results []fleet.Result, format string) error { if format == "json" { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(map[string]any{"hosts": results}) + return writeJSONEscaping(w, map[string]any{"hosts": results}, true) } if format != "text" { return fmt.Errorf("output must be text or json") @@ -264,7 +261,7 @@ func finishFleetMutation(c *cobra.Command, op string, args []string, results []f fmt.Fprintf(c.ErrOrStderr(), "%d host(s) updated but still need apply; review with 'rootfiles fleet check', then run 'rootfiles fleet apply' for: %s\n", len(pending), strings.Join(pending, ", ")) } if !allOK(results) { - return &ExitError{Code: 2} + return &ExitError{Code: exitDrift} } return nil } @@ -447,7 +444,7 @@ func newFleetTargetsCmd() *cobra.Command { return fmt.Errorf("writing fleet run log: %w", err) } if res.State != "ok" { - return &ExitError{Code: 2} + return &ExitError{Code: exitDrift} } return nil }} diff --git a/internal/cli/json_escaping_test.go b/internal/cli/json_escaping_test.go new file mode 100644 index 0000000..c2aceb0 --- /dev/null +++ b/internal/cli/json_escaping_test.go @@ -0,0 +1,31 @@ +package cli + +import ( + "bytes" + "encoding/json" + "github.com/STAIxBWLB/rootfiles/internal/fleet" + "testing" +) + +func TestSharedJSONPreservesEndpointEscaping(t *testing.T) { + var native, controller bytes.Buffer + if err := writeJSON(&native, map[string]any{"value": "<&>"}); err != nil { + t.Fatal(err) + } + if got, want := native.String(), "{\n \"value\": \"<&>\"\n}\n"; got != want { + t.Fatalf("native JSON bytes changed: %q", got) + } + if err := writeFleetResults(&controller, []fleet.Result{{Host: "<&>", State: "ok"}}, "json"); err != nil { + t.Fatal(err) + } + // Compare the existing default-escaping encoder, preserving field order and newline. + var legacy bytes.Buffer + enc := json.NewEncoder(&legacy) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"hosts": []fleet.Result{{Host: "<&>", State: "ok"}}}); err != nil { + t.Fatal(err) + } + if !bytes.Equal(controller.Bytes(), legacy.Bytes()) { + t.Fatalf("fleet JSON bytes changed:\n%s\n%s", legacy.String(), controller.String()) + } +} diff --git a/internal/cli/output.go b/internal/cli/output.go index ea5e811..c80e3e4 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -81,10 +81,12 @@ func boolMetric(b bool) int { return 0 } -func writeJSON(w io.Writer, v any) error { +func writeJSON(w io.Writer, v any) error { return writeJSONEscaping(w, v, false) } + +func writeJSONEscaping(w io.Writer, v any, escapeHTML bool) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") - enc.SetEscapeHTML(false) + enc.SetEscapeHTML(escapeHTML) return enc.Encode(v) } diff --git a/internal/cli/report_helpers_test.go b/internal/cli/report_helpers_test.go index 9551b2f..c2becff 100644 --- a/internal/cli/report_helpers_test.go +++ b/internal/cli/report_helpers_test.go @@ -117,7 +117,7 @@ func captureApplySummary(t *testing.T, cfg *config.Config) string { old := os.Stdout os.Stdout = out defer func() { os.Stdout = old; _ = out.Close() }() - printApplySummary(cfg) + printApplySummary(out, cfg) if err := out.Close(); err != nil { t.Fatal(err) } diff --git a/internal/cli/rollback.go b/internal/cli/rollback.go index 287beb3..189b6e7 100644 --- a/internal/cli/rollback.go +++ b/internal/cli/rollback.go @@ -43,7 +43,7 @@ func newRollbackCmd() *cobra.Command { return fmt.Errorf("loading backup %s: %w", args[0], err) } for _, a := range preview { - fmt.Fprintln(out, " → "+a) + ui.WriteLine(out, " "+ui.MarkPending+" "+a) } if dryRun { return nil diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go index 830d478..7b17ec4 100644 --- a/internal/cli/runtime.go +++ b/internal/cli/runtime.go @@ -234,7 +234,7 @@ func auditf(cmd *cobra.Command, msg string, args ...any) { // newLogger returns the operator-facing logger (stderr text) teed into the // audit log for real (non-dry-run) mutating runs. func newLogger(cmd *cobra.Command, dryRun bool, level slog.Level) *slog.Logger { - console := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}) + console := slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{Level: level}) if dryRun || readOnlyFlagSet(cmd) || cmd.Annotations[annotMutates] != "true" { return slog.New(console) } diff --git a/internal/cli/status.go b/internal/cli/status.go index e8132d1..46c542c 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -65,6 +65,7 @@ func runStatus(cmd *cobra.Command, _ []string) error { logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) rc := readOnlyRunContext(cfg, sysInfo, logger) + rc.Out = cmd.OutOrStdout() if rc.Config.Users.HomeBase != "" { warnStatusMetadata(rc) // on stderr, so JSON stays clean diff --git a/internal/cli/tenant.go b/internal/cli/tenant.go index 8e91842..166803a 100644 --- a/internal/cli/tenant.go +++ b/internal/cli/tenant.go @@ -13,6 +13,7 @@ import ( "github.com/STAIxBWLB/rootfiles/internal/config" "github.com/STAIxBWLB/rootfiles/internal/module" "github.com/STAIxBWLB/rootfiles/internal/tenant" + "github.com/STAIxBWLB/rootfiles/internal/ui" ) func newTenantCmd() *cobra.Command { @@ -72,7 +73,7 @@ func recordTenant(t, action string, err error) { result = "error: " + strings.SplitN(err.Error(), "\n", 2)[0] } if aerr := tenant.Audit(actor, t, action, result); aerr != nil { - fmt.Fprintf(os.Stderr, "⚠ tenant audit log: %v\n", aerr) + ui.WriteText(os.Stderr, ui.MarkWarn+" tenant audit log: %v\n", aerr) } } @@ -269,7 +270,7 @@ func newTenantApplyCmd() *cobra.Command { recordTenant(t, "apply", err) } if err != nil { - fmt.Fprintf(os.Stderr, "✗ %s: %v\n", t, err) + ui.WriteText(os.Stderr, ui.MarkFail+" %s: %v\n", t, err) failed = append(failed, t) } } @@ -518,7 +519,7 @@ host's applied configuration. Every attempt is audited.`, actor, viaSudo := tenant.Actor() refuse := func(err error) error { if aerr := tenant.Audit(actor, t, "ops "+action, "refused: "+err.Error()); aerr != nil { - fmt.Fprintf(os.Stderr, "⚠ tenant audit log: %v\n", aerr) + ui.WriteText(os.Stderr, ui.MarkWarn+" tenant audit log: %v\n", aerr) } return err } @@ -594,7 +595,7 @@ running: it only rewrites each tenant's derived files. It still needs root.`, err = m.Sync(cmd.Context(), s) } if err != nil { - fmt.Fprintf(os.Stderr, "✗ %s: %v\n", t, err) + ui.WriteText(os.Stderr, ui.MarkFail+" %s: %v\n", t, err) failed = append(failed, t) } } diff --git a/internal/cli/testdata/output-t4/base.after.golden b/internal/cli/testdata/output-t4/base.after.golden new file mode 100644 index 0000000..b47ca82 --- /dev/null +++ b/internal/cli/testdata/output-t4/base.after.golden @@ -0,0 +1,10 @@ +Profile: base +Mode: dry-run (no changes will be made) +Modules: locale, packages, ssh + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → packages: Install 18 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common] + → ssh: Deploy custom sshd configuration diff --git a/internal/cli/testdata/output-t4/base.before.golden b/internal/cli/testdata/output-t4/base.before.golden new file mode 100644 index 0000000..1b5bbd6 --- /dev/null +++ b/internal/cli/testdata/output-t4/base.before.golden @@ -0,0 +1,10 @@ +Profile: base +Mode: dry-run (no changes will be made) +Modules: locale, packages, ssh + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → packages: Install 18 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common] + → ssh: Deploy custom sshd configuration diff --git a/internal/cli/testdata/output-t4/dgx.after.golden b/internal/cli/testdata/output-t4/dgx.after.golden new file mode 100644 index 0000000..d3bd73d --- /dev/null +++ b/internal/cli/testdata/output-t4/dgx.after.golden @@ -0,0 +1,30 @@ +Profile: dgx +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, docker, nvidia, gpu, cloudflared, storage, network + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Install Docker CE + → docker: Set Docker daemon data-root = /raid/docker + → docker: Enable and start Docker service + → docker: Restart Docker after daemon configuration changes + → nvidia: Install NVIDIA Container Toolkit + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + → storage: Create data directory /raid/data + → storage: Create symlink /data → /raid/data + → storage: Create Docker storage directory /raid/docker + → network: Install UFW firewall + → network: Allow port 22 + → network: Allow port 80 + → network: Allow port 443 + → network: Enable UFW firewall diff --git a/internal/cli/testdata/output-t4/dgx.before.golden b/internal/cli/testdata/output-t4/dgx.before.golden new file mode 100644 index 0000000..7ad484d --- /dev/null +++ b/internal/cli/testdata/output-t4/dgx.before.golden @@ -0,0 +1,30 @@ +Profile: dgx +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, docker, nvidia, gpu, cloudflared, storage, network + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Install Docker CE + → docker: Set Docker daemon data-root = /raid/docker + → docker: Enable and start Docker service + → docker: Restart Docker after daemon configuration changes + → nvidia: Install NVIDIA Container Toolkit + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + → storage: Create data directory /raid/data + → storage: Create symlink /data → /raid/data + → storage: Create Docker storage directory /raid/docker + → network: Install UFW firewall + → network: Allow port 22 + → network: Allow port 80 + → network: Allow port 443 + → network: Enable UFW firewall diff --git a/internal/cli/testdata/output-t4/full.after.golden b/internal/cli/testdata/output-t4/full.after.golden new file mode 100644 index 0000000..5f753ea --- /dev/null +++ b/internal/cli/testdata/output-t4/full.after.golden @@ -0,0 +1,19 @@ +Profile: full +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, docker, cloudflared, storage + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Install Docker CE + → docker: Enable and start Docker service + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied diff --git a/internal/cli/testdata/output-t4/full.before.golden b/internal/cli/testdata/output-t4/full.before.golden new file mode 100644 index 0000000..d68b3fb --- /dev/null +++ b/internal/cli/testdata/output-t4/full.before.golden @@ -0,0 +1,19 @@ +Profile: full +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, docker, cloudflared, storage + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Install Docker CE + → docker: Enable and start Docker service + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied diff --git a/internal/cli/testdata/output-t4/gpu-server.after.golden b/internal/cli/testdata/output-t4/gpu-server.after.golden new file mode 100644 index 0000000..ca3e633 --- /dev/null +++ b/internal/cli/testdata/output-t4/gpu-server.after.golden @@ -0,0 +1,24 @@ +Profile: gpu-server +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, docker, nvidia, gpu, cloudflared, storage, network + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Install Docker CE + → docker: Enable and start Docker service + → nvidia: Install NVIDIA Container Toolkit + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied + → network: Install UFW firewall + → network: Allow port 22 + → network: Enable UFW firewall diff --git a/internal/cli/testdata/output-t4/gpu-server.before.golden b/internal/cli/testdata/output-t4/gpu-server.before.golden new file mode 100644 index 0000000..96f59b2 --- /dev/null +++ b/internal/cli/testdata/output-t4/gpu-server.before.golden @@ -0,0 +1,24 @@ +Profile: gpu-server +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, docker, nvidia, gpu, cloudflared, storage, network + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Install Docker CE + → docker: Enable and start Docker service + → nvidia: Install NVIDIA Container Toolkit + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied + → network: Install UFW firewall + → network: Allow port 22 + → network: Enable UFW firewall diff --git a/internal/cli/testdata/output-t4/minimal.after.golden b/internal/cli/testdata/output-t4/minimal.after.golden new file mode 100644 index 0000000..fc019d3 --- /dev/null +++ b/internal/cli/testdata/output-t4/minimal.after.golden @@ -0,0 +1,16 @@ +Profile: minimal +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, cloudflared + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → cloudflared: Install cloudflared binary diff --git a/internal/cli/testdata/output-t4/minimal.before.golden b/internal/cli/testdata/output-t4/minimal.before.golden new file mode 100644 index 0000000..53d8ac0 --- /dev/null +++ b/internal/cli/testdata/output-t4/minimal.before.golden @@ -0,0 +1,16 @@ +Profile: minimal +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security, cloudflared + + → locale: Install locale generation tools + → locale: Generate locale en_US.UTF-8 + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + → users: Set default useradd HOME to /home + → ssh: Deploy custom sshd configuration + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → cloudflared: Install cloudflared binary diff --git a/internal/cli/testdata/output-t4/results.json b/internal/cli/testdata/output-t4/results.json new file mode 100644 index 0000000..290b3b9 --- /dev/null +++ b/internal/cli/testdata/output-t4/results.json @@ -0,0 +1,98 @@ +[ + { + "profile": "base", + "stage": "before", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common\"\n" + }, + { + "profile": "base", + "stage": "after", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common\"\n" + }, + { + "profile": "minimal", + "stage": "before", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\n" + }, + { + "profile": "minimal", + "stage": "after", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\n" + }, + { + "profile": "dgx", + "stage": "before", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\ntime= level=INFO msg=query cmd=\"systemctl is-active docker\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n nvidia-container-toolkit\"\ntime= level=INFO msg=query cmd=\"systemctl list-unit-files nvidia-persistenced.service --no-legend\"\ntime= level=INFO msg=query cmd=\"systemctl list-unit-files nvidia-fabricmanager.service --no-legend\"\n" + }, + { + "profile": "dgx", + "stage": "after", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\ntime= level=INFO msg=query cmd=\"systemctl is-active docker\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n nvidia-container-toolkit\"\ntime= level=INFO msg=query cmd=\"systemctl list-unit-files nvidia-persistenced.service --no-legend\"\ntime= level=INFO msg=query cmd=\"systemctl list-unit-files nvidia-fabricmanager.service --no-legend\"\n" + }, + { + "profile": "gpu-server", + "stage": "before", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\ntime= level=INFO msg=query cmd=\"systemctl is-active docker\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n nvidia-container-toolkit\"\ntime= level=INFO msg=query cmd=\"systemctl list-unit-files nvidia-persistenced.service --no-legend\"\n" + }, + { + "profile": "gpu-server", + "stage": "after", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\ntime= level=INFO msg=query cmd=\"systemctl is-active docker\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n nvidia-container-toolkit\"\ntime= level=INFO msg=query cmd=\"systemctl list-unit-files nvidia-persistenced.service --no-legend\"\n" + }, + { + "profile": "full", + "stage": "before", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\ntime= level=INFO msg=query cmd=\"systemctl is-active docker\"\n" + }, + { + "profile": "full", + "stage": "after", + "system_fixture": "ubuntu", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n chrony ntpsec ntp openntpd\"\ntime= level=INFO msg=query cmd=\"dpkg-query -W -f=${Package} ${Status}\\\\n unattended-upgrades fail2ban python3-systemd systemd-timesyncd\"\ntime= level=INFO msg=query cmd=\"timedatectl show -p NTP --value\"\ntime= level=INFO msg=query cmd=\"systemctl is-active docker\"\n" + }, + { + "profile": "rocky", + "stage": "before", + "system_fixture": "rocky", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"localectl list-locales\"\ntime= level=INFO msg=query cmd=\"rpm -q --qf %{NAME}\\\\n ca-certificates curl wget gnupg2 git vim-enhanced tmux tree jq unzip zip gcc gcc-c++ make glibc-langpack-en zsh openssh-server chrony dnf-automatic curl-minimal\"\ntime= level=INFO msg=query cmd=\"rpm -q --qf %{NAME}\\\\n dnf-automatic chrony\"\ntime= level=INFO msg=query cmd=\"python3 -c import dnf\\nimport os\\nimport sys\\nimport xml.etree.ElementTree as ET\\n\\nrequired = set((\\\"baseos\\\", \\\"appstream\\\"))\\nbase = dnf.Base()\\nbase.conf.cacheonly = True\\nbase.conf.substitutions.update_from_etc(base.conf.installroot)\\nstrict = len(sys.argv) > 1 and sys.argv[1] == \\\"strict\\\"\\nbase.read_all_repos()\\nrepos = list(base.repos.iter_enabled())\\nif not repos:\\n raise RuntimeError(\\\"no enabled DNF repositories\\\")\\nenabled = set(repo.id for repo in repos)\\nmissing_required = required - enabled\\nif missing_required:\\n raise RuntimeError(\\\"required Rocky security repositories are not enabled: \\\" + \\\",\\\".join(sorted(missing_required)))\\nverified = []\\nrefresh = []\\ncoverage_gaps = []\\nfor repo in repos:\\n required_repo = repo.id in required\\n try:\\n repo.load()\\n except dnf.exceptions.RepoError as exc:\\n if strict and required_repo:\\n raise\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if strict and repo._repo.isExpired():\\n if required_repo:\\n raise RuntimeError(\\\"expired DNF metadata for required repository \\\" + repo.id)\\n coverage_gaps.append(repo.id)\\n continue\\n path = repo.get_metadata_path(\\\"updateinfo\\\")\\n if not path:\\n if strict and required_repo:\\n raise RuntimeError(\\\"missing updateinfo metadata for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if not os.path.isfile(path) or os.path.getsize(path) == 0:\\n if strict and required_repo:\\n raise RuntimeError(\\\"missing updateinfo metadata for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if path.endswith(\\\".solvx\\\"):\\n pass\\n else:\\n content = repo.get_metadata_content(\\\"updateinfo\\\")\\n if not content:\\n if strict and required_repo:\\n raise RuntimeError(\\\"empty updateinfo metadata for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n try:\\n root = ET.fromstring(content)\\n except ET.ParseError:\\n if strict and required_repo:\\n raise\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if root.tag.rsplit(\\\"}\\\", 1)[-1] != \\\"updates\\\":\\n if strict and required_repo:\\n raise RuntimeError(\\\"invalid updateinfo root for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n verified.append(repo.id)\\nbase.close()\\nif refresh:\\n print(\\\"rootfiles-updateinfo-refresh-needed:\\\" + \\\",\\\".join(refresh))\\nelse:\\n print(\\\"rootfiles-updateinfo-ok:\\\" + \\\",\\\".join(sorted(required.intersection(verified))))\\n if coverage_gaps:\\n print(\\\"rootfiles-updateinfo-coverage-gap:\\\" + \\\",\\\".join(sorted(set(coverage_gaps))))\\n\"\ntime= level=INFO msg=query cmd=\"systemctl is-active dnf-automatic-install.timer\"\ntime= level=INFO msg=query cmd=\"systemctl is-active chronyd\"\n" + }, + { + "profile": "rocky", + "stage": "after", + "system_fixture": "rocky", + "stderr_clock_normalized": true, + "exit": 0, + "stderr": "time= level=INFO msg=query cmd=\"localectl list-locales\"\ntime= level=INFO msg=query cmd=\"rpm -q --qf %{NAME}\\\\n ca-certificates curl wget gnupg2 git vim-enhanced tmux tree jq unzip zip gcc gcc-c++ make glibc-langpack-en zsh openssh-server chrony dnf-automatic curl-minimal\"\ntime= level=INFO msg=query cmd=\"rpm -q --qf %{NAME}\\\\n dnf-automatic chrony\"\ntime= level=INFO msg=query cmd=\"python3 -c import dnf\\nimport os\\nimport sys\\nimport xml.etree.ElementTree as ET\\n\\nrequired = set((\\\"baseos\\\", \\\"appstream\\\"))\\nbase = dnf.Base()\\nbase.conf.cacheonly = True\\nbase.conf.substitutions.update_from_etc(base.conf.installroot)\\nstrict = len(sys.argv) > 1 and sys.argv[1] == \\\"strict\\\"\\nbase.read_all_repos()\\nrepos = list(base.repos.iter_enabled())\\nif not repos:\\n raise RuntimeError(\\\"no enabled DNF repositories\\\")\\nenabled = set(repo.id for repo in repos)\\nmissing_required = required - enabled\\nif missing_required:\\n raise RuntimeError(\\\"required Rocky security repositories are not enabled: \\\" + \\\",\\\".join(sorted(missing_required)))\\nverified = []\\nrefresh = []\\ncoverage_gaps = []\\nfor repo in repos:\\n required_repo = repo.id in required\\n try:\\n repo.load()\\n except dnf.exceptions.RepoError as exc:\\n if strict and required_repo:\\n raise\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if strict and repo._repo.isExpired():\\n if required_repo:\\n raise RuntimeError(\\\"expired DNF metadata for required repository \\\" + repo.id)\\n coverage_gaps.append(repo.id)\\n continue\\n path = repo.get_metadata_path(\\\"updateinfo\\\")\\n if not path:\\n if strict and required_repo:\\n raise RuntimeError(\\\"missing updateinfo metadata for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if not os.path.isfile(path) or os.path.getsize(path) == 0:\\n if strict and required_repo:\\n raise RuntimeError(\\\"missing updateinfo metadata for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if path.endswith(\\\".solvx\\\"):\\n pass\\n else:\\n content = repo.get_metadata_content(\\\"updateinfo\\\")\\n if not content:\\n if strict and required_repo:\\n raise RuntimeError(\\\"empty updateinfo metadata for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n try:\\n root = ET.fromstring(content)\\n except ET.ParseError:\\n if strict and required_repo:\\n raise\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n if root.tag.rsplit(\\\"}\\\", 1)[-1] != \\\"updates\\\":\\n if strict and required_repo:\\n raise RuntimeError(\\\"invalid updateinfo root for enabled repository \\\" + repo.id)\\n if required_repo:\\n refresh.append(repo.id)\\n else:\\n coverage_gaps.append(repo.id)\\n continue\\n verified.append(repo.id)\\nbase.close()\\nif refresh:\\n print(\\\"rootfiles-updateinfo-refresh-needed:\\\" + \\\",\\\".join(refresh))\\nelse:\\n print(\\\"rootfiles-updateinfo-ok:\\\" + \\\",\\\".join(sorted(required.intersection(verified))))\\n if coverage_gaps:\\n print(\\\"rootfiles-updateinfo-coverage-gap:\\\" + \\\",\\\".join(sorted(set(coverage_gaps))))\\n\"\ntime= level=INFO msg=query cmd=\"systemctl is-active dnf-automatic-install.timer\"\ntime= level=INFO msg=query cmd=\"systemctl is-active chronyd\"\n" + } +] diff --git a/internal/cli/testdata/output-t4/rocky.after.golden b/internal/cli/testdata/output-t4/rocky.after.golden new file mode 100644 index 0000000..719854b --- /dev/null +++ b/internal/cli/testdata/output-t4/rocky.after.golden @@ -0,0 +1,15 @@ +Profile: rocky +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security + + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 19 packages: [ca-certificates curl wget gnupg2 git vim-enhanced tmux tree jq unzip zip gcc gcc-c++ make glibc-langpack-en zsh openssh-server chrony dnf-automatic] + → users: Set default useradd HOME to /home + → ssh: Enable Rocky sshd_config.d managed drop-ins + → ssh: Deploy custom sshd configuration + → security: Install [dnf-automatic chrony] + → security: Configure security-only automatic updates for Rocky baseos/appstream (no reboot, GPU packages excluded) + → security: Enable DNF automatic security update timer + → security: Enable chronyd time synchronization diff --git a/internal/cli/testdata/output-t4/rocky.before.golden b/internal/cli/testdata/output-t4/rocky.before.golden new file mode 100644 index 0000000..a483cb3 --- /dev/null +++ b/internal/cli/testdata/output-t4/rocky.before.golden @@ -0,0 +1,15 @@ +Profile: rocky +Mode: dry-run (no changes will be made) +Modules: locale, system, packages, users, ssh, security + + → locale: Set locale to en_US.UTF-8 + → locale: Set timezone to Asia/Seoul + → system: Cap journald at 2G + → packages: Install 19 packages: [ca-certificates curl wget gnupg2 git vim-enhanced tmux tree jq unzip zip gcc gcc-c++ make glibc-langpack-en zsh openssh-server chrony dnf-automatic] + → users: Set default useradd HOME to /home + → ssh: Enable Rocky sshd_config.d managed drop-ins + → ssh: Deploy custom sshd configuration + → security: Install [dnf-automatic chrony] + → security: Configure security-only automatic updates for Rocky baseos/appstream (no reboot, GPU packages excluded) + → security: Enable DNF automatic security update timer + → security: Enable chronyd time synchronization diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index 504cebf..1e81717 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -101,7 +101,7 @@ the binary and any configured VLAN are managed. "install" is an alias of setup.` if err != nil { return fmt.Errorf("restarting cloudflared: %w", err) } - fmt.Println("cloudflared service restarted.") + ui.WriteLine(cmd.OutOrStdout(), "cloudflared service restarted.") return nil }, }) @@ -166,24 +166,25 @@ func tunnelUpdateCheck(ctx context.Context, rc *module.RunContext) error { return fmt.Errorf("fetching latest cloudflared release: %w", err) } - ui.WriteSection(os.Stdout, "Cloudflared update check") + out := rc.Output() + ui.WriteSection(out, "Cloudflared update check") current := module.InspectCloudflared(ctx, rc.Runner, cloudflaredPath).Version if current == "" { - ui.WriteKV(os.Stdout, "Current", ui.StyleHint.Render("(not installed)")) + ui.WriteKV(out, "Current", ui.StyleHint.Render("(not installed)")) } else { - ui.WriteKV(os.Stdout, "Current", current) + ui.WriteKV(out, "Current", current) } - ui.WriteKV(os.Stdout, "Latest", latest) + ui.WriteKV(out, "Latest", latest) switch { case current == "": - ui.WriteHint(os.Stdout, "run `sudo rootfiles tunnel setup` to install.") + ui.WriteHint(out, "run `sudo rootfiles tunnel setup` to install.") return &ExitError{Code: exitDrift} case normalizeVersion(current) == normalizeVersion(latest): - fmt.Println(" " + ui.StyleSuccess.Render(ui.MarkOK+" up to date.")) + ui.WriteLine(out, " "+ui.StyleSuccess.Render(ui.MarkOK+" up to date.")) default: - ui.WriteHint(os.Stdout, "run `sudo rootfiles tunnel update` to upgrade.") + ui.WriteHint(out, "run `sudo rootfiles tunnel update` to upgrade.") return &ExitError{Code: exitDrift} } return nil @@ -247,6 +248,7 @@ func loadRunContext(cmd *cobra.Command, tolerateLoadErr bool, modules []string) return &module.RunContext{ Config: cfg, + Out: cmd.OutOrStdout(), Runner: runner, APT: pm, DryRun: dryRun, diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 6599bca..4e75257 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -20,6 +20,7 @@ import ( "github.com/spf13/cobra" "github.com/STAIxBWLB/rootfiles/internal/module" + "github.com/STAIxBWLB/rootfiles/internal/ui" ) const ( @@ -34,6 +35,7 @@ func newUpgradeCmd(currentVersion string) *cobra.Command { Aliases: []string{"upgrade"}, Short: "Self-update rootfiles to the latest (or specified) version", RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() checkOnly, _ := cmd.Flags().GetBool("check") targetVersion, _ := cmd.Flags().GetString("version") dryRun, _ := cmd.Flags().GetBool("dry-run") @@ -43,26 +45,26 @@ func newUpgradeCmd(currentVersion string) *cobra.Command { // Resolve target version if targetVersion == "" { - fmt.Print("Checking latest version... ") + ui.WriteRaw(out, "Checking latest version... ") latest, err := fetchLatestVersion(ctx) if err != nil { return fmt.Errorf("fetching latest version: %w", err) } targetVersion = latest - fmt.Println(targetVersion) + ui.WriteLine(out, targetVersion) } current := normalizeVersion(currentVersion) target := normalizeVersion(targetVersion) if current == target { - fmt.Printf("Already at version %s, nothing to do.\n", targetVersion) + ui.WriteText(out, "Already at version %s, nothing to do.\n", targetVersion) return nil } - fmt.Printf("Current: %s → Target: %s\n", currentVersion, targetVersion) + ui.WriteText(out, "Current: %s "+ui.MarkPending+" Target: %s\n", currentVersion, targetVersion) if checkOnly { - fmt.Println("Update available. Run `sudo rootfiles update` to install.") + ui.WriteLine(out, "Update available. Run `sudo rootfiles update` to install.") return &ExitError{Code: exitDrift} } @@ -79,30 +81,30 @@ func newUpgradeCmd(currentVersion string) *cobra.Command { } defer os.RemoveAll(tmpDir) - fmt.Print("Downloading... ") + ui.WriteRaw(out, "Downloading... ") archivePath, err := downloadRelease(ctx, targetVersion, osName, arch, tmpDir) if err != nil { return fmt.Errorf("download failed: %w", err) } - fmt.Println("OK") + ui.WriteLine(out, "OK") // Verify checksum - fmt.Print("Verifying checksum... ") + ui.WriteRaw(out, "Verifying checksum... ") if err := verifyChecksum(ctx, archivePath, targetVersion); err != nil { return fmt.Errorf("checksum verification failed: %w", err) } - fmt.Println("OK") + ui.WriteLine(out, "OK") // Extract - fmt.Print("Extracting... ") + ui.WriteRaw(out, "Extracting... ") newBinary, err := extractBinary(archivePath, tmpDir) if err != nil { return fmt.Errorf("extraction failed: %w", err) } - fmt.Println("OK") + ui.WriteLine(out, "OK") if dryRun { - fmt.Printf("Dry-run: would replace binary with %s\n", newBinary) + ui.WriteText(out, "Dry-run: would replace binary with %s\n", newBinary) return nil } @@ -116,11 +118,11 @@ func newUpgradeCmd(currentVersion string) *cobra.Command { return fmt.Errorf("resolving symlinks: %w", err) } - fmt.Printf("Replacing %s... ", currentBinary) + ui.WriteText(out, "Replacing %s... ", currentBinary) if err := replaceBinary(newBinary, currentBinary); err != nil { return fmt.Errorf("replace failed: %w", err) } - fmt.Println("OK") + ui.WriteLine(out, "OK") // Ensure the `root` alias symlink exists next to the binary. The // installer creates this on fresh installs; users upgrading from @@ -131,10 +133,10 @@ func newUpgradeCmd(currentVersion string) *cobra.Command { // (rare, but possible) doesn't turn a successful upgrade into a // failed one. if link, err := ensureRootAlias(currentBinary); err == nil && link != "" { - fmt.Printf("Symlink: %s -> %s\n", link, filepath.Base(currentBinary)) + ui.WriteText(out, "Symlink: %s -> %s\n", link, filepath.Base(currentBinary)) } - fmt.Printf("Updated to %s\n", targetVersion) + ui.WriteText(out, "Updated to %s\n", targetVersion) // Updating replaces only the binary; a release that changes // generated files still needs apply. Check exits 2 when it plans changes. checkCtx, cancelCheck := context.WithTimeout(cmd.Context(), 2*time.Minute) @@ -142,7 +144,7 @@ func newUpgradeCmd(currentVersion string) *cobra.Command { check := osexec.CommandContext(checkCtx, currentBinary, "check") var exitErr *osexec.ExitError if err := check.Run(); errors.As(err, &exitErr) && exitErr.ExitCode() == exitDrift { - fmt.Println("Apply pending: this release plans changes on this host. Review with `sudo rootfiles check`, then run `sudo rootfiles apply`.") + ui.WriteLine(out, "Apply pending: this release plans changes on this host. Review with `sudo rootfiles check`, then run `sudo rootfiles apply`.") } return nil }, diff --git a/internal/cli/user.go b/internal/cli/user.go index a96ed5d..a90389b 100644 --- a/internal/cli/user.go +++ b/internal/cli/user.go @@ -311,16 +311,16 @@ func newUserPasswdCmd() *cobra.Command { return fmt.Errorf("no users to process") } - fmt.Printf("Target users (%d):\n", len(entries)) + ui.WriteText(cmd.OutOrStdout(), "Target users (%d):\n", len(entries)) for _, e := range entries { - fmt.Printf(" - %s\n", e.Username) + ui.WriteText(cmd.OutOrStdout(), " - %s\n", e.Username) } ok, err := ui.Confirm("Set passwords for these users?", rc.Yes) if err != nil { return err } if !ok { - fmt.Println("Cancelled.") + ui.WriteLine(cmd.OutOrStdout(), "Cancelled.") return nil } diff --git a/internal/cli/writer_routing_test.go b/internal/cli/writer_routing_test.go new file mode 100644 index 0000000..69bdad6 --- /dev/null +++ b/internal/cli/writer_routing_test.go @@ -0,0 +1,192 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/STAIxBWLB/rootfiles/internal/config" + "github.com/STAIxBWLB/rootfiles/internal/exec" + "github.com/STAIxBWLB/rootfiles/internal/module" + "github.com/STAIxBWLB/rootfiles/internal/ui" +) + +func unexpectedProcessStdout(t *testing.T, run func()) string { + t.Helper() + name := filepath.Join(t.TempDir(), "process-stdout") + file, err := os.Create(name) + if err != nil { + t.Fatal(err) + } + previous := os.Stdout + os.Stdout = file + defer func() { os.Stdout = previous; _ = file.Close() }() + run() + os.Stdout = previous + if err := file.Close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestApplySummaryWritesOnlyToExplicitWriter(t *testing.T) { + var output bytes.Buffer + cfg := &config.Config{Timezone: "UTC"} + leaked := unexpectedProcessStdout(t, func() { + printApplySummary(&output, cfg) + if err := configureInteractive(&output, cfg, true, false); err != nil { + t.Fatal(err) + } + }) + if got, want := output.String(), "\n=== Summary ===\n Timezone: UTC\n"; got != want || leaked != "" { + t.Fatalf("summary=%q want=%q process stdout=%q", got, want, leaked) + } +} + +func TestApplyRoutesCommandAndModuleOutput(t *testing.T) { + aliasCLIFixture(t) + site := filepath.Join(t.TempDir(), "site.yaml") + if err := os.WriteFile(site, []byte("timezone: UTC\nmodules:\n packages:\n enabled: true\n"), 0o600); err != nil { + t.Fatal(err) + } + var output, diagnostic bytes.Buffer + root := NewRootCmd("test", "test") + root.SetOut(&output) + root.SetErr(&diagnostic) + root.SetArgs([]string{"apply", "--config", site, "--yes", "--dry-run"}) + leaked := unexpectedProcessStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{"Profile: minimal\n", "Mode: dry-run (no changes will be made)\n", "Modules: packages\n", "packages: already satisfied"} { + if !strings.Contains(output.String(), want) { + t.Errorf("command writer missing %q: %s", want, output.String()) + } + } + if leaked != "" { + t.Fatalf("apply leaked process stdout: %q", leaked) + } +} + +func TestUpdateCheckKeepsTextAndExitCodeOnCommandWriter(t *testing.T) { + for _, tc := range []struct { + version, want string + code int + }{ + {"v1.0.0", "Already at version v1.0.0, nothing to do.\n", 0}, + {"v2.0.0", "Current: v1.0.0 " + ui.MarkPending + " Target: v2.0.0\nUpdate available. Run `sudo rootfiles update` to install.\n", exitDrift}, + } { + var output bytes.Buffer + root := NewRootCmd("v1.0.0", "test") + root.SetOut(&output) + root.SetErr(io.Discard) + root.SetArgs([]string{"update", "--check", "--version", tc.version}) + var runErr error + leaked := unexpectedProcessStdout(t, func() { runErr = root.Execute() }) + code := 0 + if runErr != nil { + var exit *ExitError + if !errors.As(runErr, &exit) { + t.Fatal(runErr) + } + code = exit.Code + } + if output.String() != tc.want || code != tc.code || leaked != "" { + t.Fatalf("update text=%q code=%d process stdout=%q", output.String(), code, leaked) + } + } +} + +func TestTunnelUpdateCheckUsesRunContextWriter(t *testing.T) { + previousPath, previousTransport := cloudflaredPath, http.DefaultTransport + t.Cleanup(func() { cloudflaredPath, http.DefaultTransport = previousPath, previousTransport }) + cloudflaredPath = filepath.Join(t.TempDir(), "cloudflared") + if err := os.WriteFile(cloudflaredPath, []byte("#!/bin/sh\nprintf 'cloudflared version 2026.1.0 (built fixture)\\n'\n"), 0o755); err != nil { + t.Fatal(err) + } + http.DefaultTransport = updateCheckTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"tag_name":"v2026.1.0"}`))}, nil + }) + var output bytes.Buffer + rc := &module.RunContext{Out: &output, Runner: exec.NewRunner(true, slog.New(slog.NewTextHandler(io.Discard, nil)))} + leaked := unexpectedProcessStdout(t, func() { + if err := tunnelUpdateCheck(context.Background(), rc); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{"Cloudflared update check", "Current:", "2026.1.0", "Latest:", "v2026.1.0", "up to date."} { + if !strings.Contains(output.String(), want) { + t.Errorf("tunnel writer missing %q: %s", want, output.String()) + } + } + if leaked != "" { + t.Fatalf("tunnel check leaked process stdout: %q", leaked) + } +} + +func TestUserPasswordTargetsAndContextUseCommandWriter(t *testing.T) { + aliasCLIFixture(t) + var output bytes.Buffer + previous := setCLIUserPasswords + setCLIUserPasswords = func(_ context.Context, rc *module.RunContext, entries []module.PasswordEntry, _ string, _ bool) error { + if rc.Output() != &output || len(entries) != 2 { + t.Fatalf("password context writer=%v entries=%v", rc.Out, entries) + } + ui.WriteLine(rc.Output(), "password helper output") + return nil + } + t.Cleanup(func() { setCLIUserPasswords = previous }) + root := NewRootCmd("test", "test") + root.SetOut(&output) + root.SetErr(io.Discard) + root.SetArgs([]string{"user", "passwd", "alice", "bob", "--yes", "--dry-run"}) + leaked := unexpectedProcessStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + if got, want := output.String(), "Target users (2):\n - alice\n - bob\npassword helper output\n"; got != want || leaked != "" { + t.Fatalf("password output=%q want=%q process stdout=%q", got, want, leaked) + } +} + +func TestDoctorContextWriterKeepsJSONSeparate(t *testing.T) { + aliasCLIFixture(t) + var output bytes.Buffer + previous := doctorChecks + doctorChecks = func(_ context.Context, rc *module.RunContext) []module.Finding { + if rc.Output() != &output { + t.Fatal("doctor did not pass its injected writer to the report context") + } + return []module.Finding{{Check: "fixture", Level: module.LevelOK, Detail: "healthy"}} + } + t.Cleanup(func() { doctorChecks = previous }) + root := NewRootCmd("test", "test") + root.SetOut(&output) + root.SetErr(io.Discard) + root.SetArgs([]string{"doctor", "-o", "json"}) + leaked := unexpectedProcessStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatal(err) + } + }) + var report struct { + Findings []module.Finding `json:"findings"` + } + if err := json.Unmarshal(output.Bytes(), &report); err != nil || len(report.Findings) != 1 || leaked != "" { + t.Fatalf("doctor JSON=%q error=%v process stdout=%q", output.String(), err, leaked) + } +} diff --git a/internal/exec/backup.go b/internal/exec/backup.go index 693b13f..477eb9c 100644 --- a/internal/exec/backup.go +++ b/internal/exec/backup.go @@ -203,7 +203,7 @@ func RestoreBackup(root, id string, dryRun bool) ([]string, error) { } } case e.Kind == "symlink": - actions = append(actions, fmt.Sprintf("symlink %s → %s", e.Path, e.Target)) + actions = append(actions, fmt.Sprintf("symlink %s -> %s", e.Path, e.Target)) if !dryRun { _ = os.Remove(e.Path) if err := os.Symlink(e.Target, e.Path); err != nil { diff --git a/internal/module/cloudflared.go b/internal/module/cloudflared.go index 6eec219..9cd62ab 100644 --- a/internal/module/cloudflared.go +++ b/internal/module/cloudflared.go @@ -159,7 +159,7 @@ func (m *CloudflaredModule) Check(_ context.Context, rc *RunContext) (*CheckResu if !rc.Runner.FileExists(cloudflaredBinary) { changes = append(changes, Change{ Description: "Install cloudflared binary", - Command: "download cloudflared → " + cloudflaredBinary, + Command: "download cloudflared " + ui.MarkPending + " " + cloudflaredBinary, }) } @@ -352,30 +352,30 @@ func (m *CloudflaredModule) setupVLAN(ctx context.Context, rc *RunContext) ([]st // TunnelStatus shows cloudflared and VLAN status. func TunnelStatus(ctx context.Context, rc *RunContext) error { - ui.WriteSection(os.Stdout, "Tunnel") + ui.WriteSection(rc.Output(), "Tunnel") // Binary version observation := InspectCloudflared(ctx, rc.Runner, "") if observation.Installed { if observation.QueryError == nil { - ui.WriteKV(os.Stdout, "Binary", strings.TrimSpace(observation.RawOutput)) + ui.WriteKV(rc.Output(), "Binary", strings.TrimSpace(observation.RawOutput)) } else { - ui.WriteKV(os.Stdout, "Binary", ui.StyleHint.Render("installed (version unknown)")) + ui.WriteKV(rc.Output(), "Binary", ui.StyleHint.Render("installed (version unknown)")) } } else { - ui.WriteKV(os.Stdout, "Binary", ui.StyleHint.Render("not installed")) + ui.WriteKV(rc.Output(), "Binary", ui.StyleHint.Render("not installed")) } // Service status res, err := rc.Runner.Query(ctx, "systemctl", "is-active", cloudflaredService) if err != nil { - ui.WriteKV(os.Stdout, "Service", ui.StyleHint.Render("inactive")) + ui.WriteKV(rc.Output(), "Service", ui.StyleHint.Render("inactive")) } else { state := strings.TrimSpace(res.Stdout) if state == "active" { - ui.WriteKV(os.Stdout, "Service", ui.StyleSuccess.Render("active")) + ui.WriteKV(rc.Output(), "Service", ui.StyleSuccess.Render("active")) } else { - ui.WriteKV(os.Stdout, "Service", ui.StyleHint.Render(state)) + ui.WriteKV(rc.Output(), "Service", ui.StyleHint.Render(state)) } } @@ -387,19 +387,19 @@ func TunnelStatus(ctx context.Context, rc *RunContext) error { } res, err = rc.Runner.Query(ctx, "ip", "addr", "show", iface) if err != nil { - ui.WriteKV(os.Stdout, fmt.Sprintf("VLAN (%s)", iface), ui.StyleHint.Render("not configured")) + ui.WriteKV(rc.Output(), fmt.Sprintf("VLAN (%s)", iface), ui.StyleHint.Render("not configured")) } else { // Extract address line found := false for _, line := range strings.Split(res.Stdout, "\n") { if strings.Contains(line, "inet ") { - ui.WriteKV(os.Stdout, fmt.Sprintf("VLAN (%s)", iface), strings.TrimSpace(line)) + ui.WriteKV(rc.Output(), fmt.Sprintf("VLAN (%s)", iface), strings.TrimSpace(line)) found = true break } } if !found { - ui.WriteKV(os.Stdout, fmt.Sprintf("VLAN (%s)", iface), ui.StyleHint.Render("up (no inet)")) + ui.WriteKV(rc.Output(), fmt.Sprintf("VLAN (%s)", iface), ui.StyleHint.Render("up (no inet)")) } } @@ -417,23 +417,23 @@ func TunnelUpdate(ctx context.Context, rc *RunContext, version string) error { if target == "" { latest, err := FetchLatestCloudflaredVersion(ctx) if err != nil { - fmt.Printf("Warning: could not resolve latest cloudflared release (%v); using /latest/download/ alias.\n", err) + ui.WriteText(rc.Output(), "Warning: could not resolve latest cloudflared release (%v); using /latest/download/ alias.\n", err) } else { target = latest } } - ui.WriteSection(os.Stdout, "Cloudflared update") - ui.WriteKV(os.Stdout, "Current", ui.FirstNonEmpty(current, "(not installed)")) - ui.WriteKV(os.Stdout, "Target", ui.FirstNonEmpty(target, "latest")) + ui.WriteSection(rc.Output(), "Cloudflared update") + ui.WriteKV(rc.Output(), "Current", ui.FirstNonEmpty(current, "(not installed)")) + ui.WriteKV(rc.Output(), "Target", ui.FirstNonEmpty(target, "latest")) if target != "" && current == target { - ui.WriteHint(os.Stdout, "already on target version — nothing to do.") + ui.WriteHint(rc.Output(), "already on target version — nothing to do.") return nil } - fmt.Println() - fmt.Printf("Downloading cloudflared %s ...\n", ui.FirstNonEmpty(target, "latest")) + ui.WriteLine(rc.Output()) + ui.WriteText(rc.Output(), "Downloading cloudflared %s ...\n", ui.FirstNonEmpty(target, "latest")) if err := m.installBinaryVersion(ctx, rc, target); err != nil { return err } @@ -445,9 +445,9 @@ func TunnelUpdate(ctx context.Context, rc *RunContext, version string) error { if _, err := rc.Runner.Run(ctx, "systemctl", "restart", cloudflaredService); err != nil { return fmt.Errorf("restarting cloudflared: %w", err) } - fmt.Println(ui.StyleSuccess.Render(ui.MarkOK + " cloudflared updated and service restarted.")) + ui.WriteLine(rc.Output(), ui.StyleSuccess.Render(ui.MarkOK+" cloudflared updated and service restarted.")) } else { - fmt.Println(ui.StyleSuccess.Render(ui.MarkOK + " cloudflared binary updated (service not installed, skip restart).")) + ui.WriteLine(rc.Output(), ui.StyleSuccess.Render(ui.MarkOK+" cloudflared binary updated (service not installed, skip restart).")) } return nil } @@ -550,8 +550,8 @@ func TunnelUninstall(ctx context.Context, rc *RunContext) error { } for _, w := range warnings { - fmt.Println(" " + ui.MarkWarn + " " + w) + ui.WriteLine(rc.Output(), " "+ui.MarkWarn+" "+w) } - fmt.Println("Tunnel uninstalled (service, VLAN, binary removed).") + ui.WriteLine(rc.Output(), "Tunnel uninstalled (service, VLAN, binary removed).") return nil } diff --git a/internal/module/gpu.go b/internal/module/gpu.go index b6aa11f..74b24e5 100644 --- a/internal/module/gpu.go +++ b/internal/module/gpu.go @@ -220,7 +220,7 @@ func AssignGPUs(ctx context.Context, rc *RunContext, username string, gpus []int return err } - fmt.Printf("Assigned GPUs %s to %s (method: %s)\n", gpuListStr(gpus), username, method) + ui.WriteText(rc.Output(), "Assigned GPUs %s to %s (method: %s)\n", gpuListStr(gpus), username, method) return nil } @@ -263,7 +263,7 @@ func RevokeGPUs(ctx context.Context, rc *RunContext, username string) error { if method == "env" || method == "both" { scriptPath := profileDScriptPath(username) if rc.DryRun { - fmt.Printf("[dry-run] would remove %s\n", scriptPath) + ui.WriteText(rc.Output(), "[dry-run] would remove %s\n", scriptPath) } else { if err := rc.Runner.Remove(scriptPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing %s: %w", scriptPath, err) @@ -278,7 +278,7 @@ func RevokeGPUs(ctx context.Context, rc *RunContext, username string) error { slicePath := cgroupSlicePath(u.Uid) sliceDir := filepath.Dir(slicePath) if rc.DryRun { - fmt.Printf("[dry-run] would remove %s\n", sliceDir) + ui.WriteText(rc.Output(), "[dry-run] would remove %s\n", sliceDir) } else { // Remove only our drop-in; the slice dir may hold others. if err := rc.Runner.Remove(slicePath); err != nil && !os.IsNotExist(err) { @@ -286,7 +286,7 @@ func RevokeGPUs(ctx context.Context, rc *RunContext, username string) error { } _ = rc.Runner.Remove(sliceDir) // only succeeds when empty if w := systemdReload(ctx, rc); w != "" { - fmt.Println(" " + ui.MarkWarn + " " + w) + ui.WriteLine(rc.Output(), " "+ui.MarkWarn+" "+w) } } } @@ -302,7 +302,7 @@ func RevokeGPUs(ctx context.Context, rc *RunContext, username string) error { } } - fmt.Printf("Revoked GPU allocation for %s\n", username) + ui.WriteText(rc.Output(), "Revoked GPU allocation for %s\n", username) return nil } @@ -311,28 +311,28 @@ func ListGPUAllocations(rc *RunContext) error { WarnUntrustedMetadata(rc) db, err := loadGPUDB(rc) if err != nil || len(db.Allocations) == 0 { - ui.WriteSection(os.Stdout, "GPU Allocations") - ui.WriteHint(os.Stdout, "No GPU allocations configured.") + ui.WriteSection(rc.Output(), "GPU Allocations") + ui.WriteHint(rc.Output(), "No GPU allocations configured.") return nil } - ui.WriteSection(os.Stdout, "GPU Allocations") + ui.WriteSection(rc.Output(), "GPU Allocations") if db.TotalGPUs > 0 { totalLabel := fmt.Sprintf("%d", db.TotalGPUs) if db.GPUModel != "" { totalLabel = fmt.Sprintf("%d (%s)", db.TotalGPUs, db.GPUModel) } - ui.WriteKV(os.Stdout, "Total GPUs", totalLabel) + ui.WriteKV(rc.Output(), "Total GPUs", totalLabel) } // Preserve the fixed-width table so integration scenarios can still grep // for usernames and GPU indices. Header is coloured; value rows are plain // to keep column alignment predictable. - fmt.Fprintln(os.Stdout) - fmt.Fprintln(os.Stdout, " "+ui.StyleSection.Render(fmt.Sprintf("%-15s %-15s %-10s %s", "USER", "GPUs", "METHOD", "UPDATED"))) - fmt.Fprintln(os.Stdout, " "+ui.StyleHint.Render(fmt.Sprintf("%-15s %-15s %-10s %s", "----", "----", "------", "-------"))) + ui.WriteLine(rc.Output()) + ui.WriteLine(rc.Output(), " "+ui.StyleSection.Render(fmt.Sprintf("%-15s %-15s %-10s %s", "USER", "GPUs", "METHOD", "UPDATED"))) + ui.WriteLine(rc.Output(), " "+ui.StyleHint.Render(fmt.Sprintf("%-15s %-15s %-10s %s", "----", "----", "------", "-------"))) for _, a := range db.Allocations { - fmt.Fprintf(os.Stdout, " %-15s %-15s %-10s %s\n", + ui.WriteText(rc.Output(), " %-15s %-15s %-10s %s\n", a.Username, gpuListStr(a.GPUs), a.Method, a.UpdatedAt) } return nil @@ -402,9 +402,9 @@ func ShowGPUStatus(ctx context.Context, rc *RunContext) error { } } - ui.WriteSection(os.Stdout, "GPU Status") - fmt.Fprintln(os.Stdout, " "+ui.StyleSection.Render(fmt.Sprintf("%-5s %-25s %-15s %-6s %s", "GPU", "MODEL", "MEMORY", "UTIL", "ASSIGNED TO"))) - fmt.Fprintln(os.Stdout, " "+ui.StyleHint.Render(fmt.Sprintf("%-5s %-25s %-15s %-6s %s", "---", "-----", "------", "----", "-----------"))) + ui.WriteSection(rc.Output(), "GPU Status") + ui.WriteLine(rc.Output(), " "+ui.StyleSection.Render(fmt.Sprintf("%-5s %-25s %-15s %-6s %s", "GPU", "MODEL", "MEMORY", "UTIL", "ASSIGNED TO"))) + ui.WriteLine(rc.Output(), " "+ui.StyleHint.Render(fmt.Sprintf("%-5s %-25s %-15s %-6s %s", "---", "-----", "------", "----", "-----------"))) for i := 0; i < totalGPUs; i++ { assignedLabel := ui.StyleHint.Render("(unassigned)") @@ -417,7 +417,7 @@ func ShowGPUStatus(ctx context.Context, rc *RunContext) error { if len(name) > 24 { name = name[:24] } - fmt.Fprintf(os.Stdout, " %-5d %-25s %5s/%-5s MB %4s%% %s\n", + ui.WriteText(rc.Output(), " %-5d %-25s %5s/%-5s MB %4s%% %s\n", i, name, info.MemUsed, info.MemTotal, info.Util, assignedLabel) } else { // GPU exists but not visible to nvidia-smi (cgroup restricted) @@ -428,7 +428,7 @@ func ShowGPUStatus(ctx context.Context, rc *RunContext) error { if len(name) > 24 { name = name[:24] } - fmt.Fprintf(os.Stdout, " %-5d %-25s %s --- %s\n", + ui.WriteText(rc.Output(), " %-5d %-25s %s --- %s\n", i, name, ui.StyleWarning.Render("(restricted)"), assignedLabel) } } @@ -570,7 +570,7 @@ func installDockerWrapper(rc *RunContext) error { return nil } if rc.DryRun { - fmt.Printf("[dry-run] would write %s\n", dockerWrapperPath) + ui.WriteText(rc.Output(), "[dry-run] would write %s\n", dockerWrapperPath) return nil } _, err := ensureFile(rc, dockerWrapperPath, expected, 0755) @@ -580,11 +580,11 @@ func installDockerWrapper(rc *RunContext) error { // removeDockerWrapper removes the Docker wrapper script if it exists. func removeDockerWrapper(ctx context.Context, rc *RunContext) { if rc.DryRun { - fmt.Printf("[dry-run] would remove %s\n", dockerWrapperPath) + ui.WriteText(rc.Output(), "[dry-run] would remove %s\n", dockerWrapperPath) return } if err := rc.Runner.Remove(dockerWrapperPath); err != nil && !os.IsNotExist(err) { - fmt.Printf(" %s removing %s: %v\n", ui.MarkWarn, dockerWrapperPath, err) + ui.WriteText(rc.Output(), " %s removing %s: %v\n", ui.MarkWarn, dockerWrapperPath, err) } } @@ -698,7 +698,7 @@ func applyAllocationMethods(ctx context.Context, rc *RunContext, username string scriptPath := profileDScriptPath(username) content := buildProfileDScript(username, gpus) if rc.DryRun { - fmt.Printf("[dry-run] would write %s\n", scriptPath) + ui.WriteText(rc.Output(), "[dry-run] would write %s\n", scriptPath) } else { if err := rc.Runner.WriteFile(scriptPath, []byte(content), 0644); err != nil { return fmt.Errorf("writing profile.d script: %w", err) @@ -714,7 +714,7 @@ func applyAllocationMethods(ctx context.Context, rc *RunContext, username string slicePath := cgroupSlicePath(u.Uid) content := buildCgroupConf(gpus) if rc.DryRun { - fmt.Printf("[dry-run] would write %s\n", slicePath) + ui.WriteText(rc.Output(), "[dry-run] would write %s\n", slicePath) } else { sliceDir := filepath.Dir(slicePath) if err := rc.Runner.MkdirAll(sliceDir, 0755); err != nil { @@ -724,7 +724,7 @@ func applyAllocationMethods(ctx context.Context, rc *RunContext, username string return fmt.Errorf("writing cgroup conf: %w", err) } if w := systemdReload(ctx, rc); w != "" { - fmt.Println(" " + ui.MarkWarn + " " + w) + ui.WriteLine(rc.Output(), " "+ui.MarkWarn+" "+w) } } } diff --git a/internal/module/lifecycle.go b/internal/module/lifecycle.go index b4244fb..003b2bd 100644 --- a/internal/module/lifecycle.go +++ b/internal/module/lifecycle.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" "time" + + "github.com/STAIxBWLB/rootfiles/internal/ui" ) // Account lifecycle beyond creation: delete, lock, key management, expiry, @@ -89,7 +91,7 @@ func DeleteUser(ctx context.Context, rc *RunContext, name string, mode DeleteHom if _, err := rc.Runner.Run(ctx, "tar", "-czf", archive, "-C", filepath.Dir(home), filepath.Base(home)); err != nil { return fmt.Errorf("archiving %s (account left intact): %w", home, err) } - fmt.Printf(" home archived to %s\n", archive) + ui.WriteText(rc.Output(), " home archived to %s\n", archive) } if err := RevokeGPUs(ctx, rc, name); err != nil && !strings.Contains(err.Error(), "no GPU allocation") { @@ -111,9 +113,9 @@ func DeleteUser(ctx context.Context, rc *RunContext, name string, mode DeleteHom if _, err := rc.Runner.Run(ctx, "rm", "-rf", "--one-file-system", home); err != nil { return fmt.Errorf("removing %s: %w", home, err) } - fmt.Printf("User %s deleted (home removed)\n", name) + ui.WriteText(rc.Output(), "User %s deleted (home removed)\n", name) default: - fmt.Printf("User %s deleted (home kept at %s)\n", name, home) + ui.WriteText(rc.Output(), "User %s deleted (home kept at %s)\n", name, home) } return nil } @@ -127,7 +129,7 @@ func LockUser(ctx context.Context, rc *RunContext, name string) error { if _, err := rc.Runner.Run(ctx, "usermod", "-L", "-e", "1", name); err != nil { return fmt.Errorf("locking %s: %w", name, err) } - fmt.Printf("User %s locked (password and SSH key logins blocked)\n", name) + ui.WriteText(rc.Output(), "User %s locked (password and SSH key logins blocked)\n", name) return nil } @@ -139,7 +141,7 @@ func UnlockUser(ctx context.Context, rc *RunContext, name string) error { if _, err := rc.Runner.Run(ctx, "usermod", "-U", "-e", "", name); err != nil { return fmt.Errorf("unlocking %s: %w", name, err) } - fmt.Printf("User %s unlocked\n", name) + ui.WriteText(rc.Output(), "User %s unlocked\n", name) return nil } @@ -158,7 +160,7 @@ func SetExpiry(ctx context.Context, rc *RunContext, name, date string) error { if _, err := rc.Runner.Run(ctx, "chage", "-E", arg, name); err != nil { return fmt.Errorf("setting expiry: %w", err) } - fmt.Printf("User %s expiry: %s\n", name, date) + ui.WriteText(rc.Output(), "User %s expiry: %s\n", name, date) return nil } @@ -175,7 +177,7 @@ func ListKeys(rc *RunContext, name string) error { data, _ := rc.Runner.ReadFile(authorizedKeysPath(u)) keys := nonEmptyLines(string(data)) if len(keys) == 0 { - fmt.Printf("%s has no authorized keys\n", name) + ui.WriteText(rc.Output(), "%s has no authorized keys\n", name) return nil } for i, k := range keys { @@ -183,7 +185,7 @@ func ListKeys(rc *RunContext, name string) error { if len(blob) > 40 { blob = blob[:25] + "…" + blob[len(blob)-12:] } - fmt.Printf("%2d %-40s %s\n", i+1, blob, keyComment(k)) + ui.WriteText(rc.Output(), "%2d %-40s %s\n", i+1, blob, keyComment(k)) } return nil } @@ -203,7 +205,7 @@ func AddKey(ctx context.Context, rc *RunContext, name, key string) error { } path := authorizedKeysPath(u) if len(missingKeys(path, []string{key})) == 0 { - fmt.Printf("key already present for %s\n", name) + ui.WriteText(rc.Output(), "key already present for %s\n", name) return nil } var lines []string @@ -219,7 +221,7 @@ func AddKey(ctx context.Context, rc *RunContext, name, key string) error { }); err != nil { return err } - fmt.Printf("key added for %s\n", name) + ui.WriteText(rc.Output(), "key added for %s\n", name) return nil } @@ -276,7 +278,7 @@ func RemoveKey(ctx context.Context, rc *RunContext, name, sel string) error { }); err != nil { return err } - fmt.Printf("removed %d key(s) for %s\n", len(removed), name) + ui.WriteText(rc.Output(), "removed %d key(s) for %s\n", len(removed), name) return nil } diff --git a/internal/module/module.go b/internal/module/module.go index 0573876..62a675e 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -3,6 +3,7 @@ package module import ( "context" "fmt" + "io" "os" "path/filepath" "strings" @@ -10,6 +11,7 @@ import ( "github.com/STAIxBWLB/rootfiles/internal/config" "github.com/STAIxBWLB/rootfiles/internal/exec" "github.com/STAIxBWLB/rootfiles/internal/state" + "github.com/STAIxBWLB/rootfiles/internal/ui" ) // Module is the interface that all rootfiles modules must implement. @@ -21,6 +23,8 @@ type Module interface { // RunContext carries config and runtime options to all modules. type RunContext struct { + // Out receives command text; nil preserves the traditional stdout default. + Out io.Writer Config *config.Config Runner *exec.Runner APT exec.PackageManager @@ -34,6 +38,14 @@ type RunContext struct { run map[string]bool } +// Output returns the command writer, retaining stdout for callers without one. +func (rc *RunContext) Output() io.Writer { + if rc.Out != nil { + return rc.Out + } + return os.Stdout +} + // moduleSucceeding reports whether module name is part of the current run // and has not failed so far, so a later module can rely on its work. // Outside RunAll and CheckAll it reports whether the config enables it. @@ -153,14 +165,14 @@ func RunAll(ctx context.Context, modules []Module, rc *RunContext) ([]state.Modu check, err := m.Check(ctx, rc) if err != nil { rc.run[m.Name()] = true - fmt.Printf(" ⚠ %s: check error: %v\n", m.Name(), err) + ui.WriteBullet(rc.Output(), ui.WarnMark(), fmt.Sprintf("%s: check error: %v", m.Name(), err)) out.Status, out.Error = "failed", "check: "+err.Error() errors = append(errors, fmt.Sprintf("%s: check: %v", m.Name(), err)) outcomes = append(outcomes, out) continue } if check.Satisfied { - fmt.Printf(" ✓ %s: already satisfied\n", m.Name()) + ui.WriteBullet(rc.Output(), ui.OKMark(), fmt.Sprintf("%s: already satisfied", m.Name())) out.Status = "satisfied" outcomes = append(outcomes, out) continue @@ -170,11 +182,11 @@ func RunAll(ctx context.Context, modules []Module, rc *RunContext) ([]state.Modu var blocked []string for _, c := range check.Changes { if c.State == ChangeBlocked { - fmt.Printf(" ✗ %s: blocked: %s\n", m.Name(), c.Description) + ui.WriteBullet(rc.Output(), ui.FailMark(), fmt.Sprintf("%s: blocked: %s", m.Name(), c.Description)) blocked = append(blocked, c.Description) continue } - fmt.Printf(" → %s: %s\n", m.Name(), c.Description) + ui.WriteBullet(rc.Output(), ui.PendingMark(), fmt.Sprintf("%s: %s", m.Name(), c.Description)) } if rc.DryRun { @@ -192,7 +204,7 @@ func RunAll(ctx context.Context, modules []Module, rc *RunContext) ([]state.Modu result, err := m.Apply(ctx, rc) if err != nil { rc.run[m.Name()] = true - fmt.Printf(" ✗ %s: %v\n", m.Name(), err) + ui.WriteBullet(rc.Output(), ui.FailMark(), fmt.Sprintf("%s: %v", m.Name(), err)) out.Status, out.Error = "failed", err.Error() errors = append(errors, fmt.Sprintf("%s: %v", m.Name(), err)) outcomes = append(outcomes, out) @@ -202,11 +214,11 @@ func RunAll(ctx context.Context, modules []Module, rc *RunContext) ([]state.Modu if result.Changed { out.Status = "changed" for _, msg := range result.Messages { - fmt.Printf(" ✓ %s: %s\n", m.Name(), msg) + ui.WriteBullet(rc.Output(), ui.OKMark(), fmt.Sprintf("%s: %s", m.Name(), msg)) } } for _, w := range result.Warnings { - fmt.Printf(" ⚠ %s: %s\n", m.Name(), w) + ui.WriteBullet(rc.Output(), ui.WarnMark(), fmt.Sprintf("%s: %s", m.Name(), w)) } out.Messages, out.Warnings = result.Messages, result.Warnings outcomes = append(outcomes, out) diff --git a/internal/module/output_profiles_test.go b/internal/module/output_profiles_test.go new file mode 100644 index 0000000..37fabca --- /dev/null +++ b/internal/module/output_profiles_test.go @@ -0,0 +1,227 @@ +package module + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/STAIxBWLB/rootfiles/internal/config" + "github.com/STAIxBWLB/rootfiles/internal/exec" +) + +// This is a real profile/module-phase contract, not a full CLI acceptance +// test: it loads each embedded profile, runs its actual Check methods through +// RunAll, and asserts dry-run outcomes and zero managed filesystem mutations. +// Only filesystem destinations are redirected; no module toggle is changed. +// The synthetic host has missing packages, a current locale, and an active +// firewalld, so UFW-enabled profiles also exercise the blocked-change path. +// +// Root captures before with original production module sources in a Go overlay, +// then after with the routed implementation. Neither capture executes Apply. +// Before captures use 769b013 plus only the output-field seam; after captures use the routed code. +func TestProfileModuleDryRunOutputContract(t *testing.T) { + for _, profile := range config.AvailableProfiles() { + t.Run(profile, func(t *testing.T) { + rc, fixtureRoot := outputProfileFixture(t, profile) + modules := NewRegistry().Resolve(rc.Config, nil) + var wantNames []string + for _, name := range config.ModuleNames() { + if rc.Config.IsModuleEnabled(name) { + wantNames = append(wantNames, name) + } + } + if err := config.ValidateCapabilities(rc.Config, rc.Config.System, wantNames); err != nil { + t.Fatal(err) + } + before := outputProfileSnapshot(t, fixtureRoot) + capture, err := os.Create(filepath.Join(fixtureRoot, "captured-output")) + if err != nil { + t.Fatal(err) + } + oldStdout := os.Stdout + os.Stdout, rc.Out = capture, capture + // Restore even if a future module panics or the test aborts. + t.Cleanup(func() { os.Stdout = oldStdout; _ = capture.Close() }) + outcomes, runErr := RunAll(context.Background(), modules, rc) + os.Stdout = oldStdout + if err := capture.Close(); err != nil { + t.Fatal(err) + } + var gotNames []string + pending, satisfied, failed := 0, 0, 0 + for _, outcome := range outcomes { + gotNames = append(gotNames, outcome.Name) + switch outcome.Status { + case "pending": + pending++ + case "satisfied": + satisfied++ + case "failed": + failed++ + if outcome.Name != "network" || !strings.Contains(outcome.Error, "firewalld") { + t.Fatalf("unexpected preview failure: %+v", outcome) + } + default: + t.Fatalf("dry-run executed or skipped a module: %+v", outcome) + } + } + if !reflect.DeepEqual(gotNames, wantNames) || pending == 0 || satisfied == 0 { + t.Fatalf("profile coverage names=%v want=%v pending=%d satisfied=%d", gotNames, wantNames, pending, satisfied) + } + wantBlocked := rc.Config.Modules.Network.Enabled && rc.Config.Modules.Network.UFW + if (runErr != nil) != wantBlocked || failed != map[bool]int{false: 0, true: 1}[wantBlocked] { + t.Fatalf("blocked preview result: failed=%d err=%v wantBlocked=%t", failed, runErr, wantBlocked) + } + if after := outputProfileSnapshot(t, fixtureRoot); !reflect.DeepEqual(before, after) || rc.Runner.Backup.Used() { + t.Fatalf("dry-run changed managed paths: before=%v after=%v backup=%t", before, after, rc.Runner.Backup.Used()) + } + data, err := os.ReadFile(capture.Name()) + if err != nil { + t.Fatal(err) + } + got := strings.ReplaceAll(string(data), fixtureRoot, "") + stage := "after" + + golden := filepath.Join("testdata", "output-t4", profile+"."+stage+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read reviewed output golden: %v; restore the reviewed after capture from version control", err) + } + if got != string(want) { + t.Errorf("dry-run output changed\n--- reviewed\n%s\n--- current\n%s", want, got) + } + }) + } +} + +func outputProfileFixture(t *testing.T, profile string) (*RunContext, string) { + t.Helper() + root := t.TempDir() + // Keep every read destination independent of other tests and the host. + paths := []*string{&passwdPath, &shadowPath, &sshdConfigPath, &sshdConfigDir, &sshdDropInPath, &sshdPrivilegeSeparationDir, &useraddDefaultsFile, &sudoersDir, + &defaultLocalePath, &rockyLocalePath, &timezonePath, &localtimePath, &zoneinfoDir, &hostnamePath, &hostsPath, &procSwapsPath, &swapfilePath, &fstabPath, &sysctlPath, &journaldConfPath, + &autoUpgradesPath, &unattendedPolicyPath, &fail2banJailPath, &rockyAutomaticConfigPath, &rockyPlatformPython, &dockerDaemonJSONPath, &dockerComposePath, &dockerWrapperPath, + &gpuDeviceDir, &gpuProfileDir, &gpuSystemdDir, &cloudflaredBinary, &cloudflaredUnitPath, &cloudflaredEnvPath, &vlanNetdevPath, &vlanNetworkPath} + for i, destination := range paths { + old := *destination + *destination = filepath.Join(root, "host", fmt.Sprintf("%02d-%s", i, filepath.Base(old))) + t.Cleanup(func() { *destination = old }) + } + oldPlugins, oldLegacy := composePluginPaths, legacyUnattendedPaths + composePluginPaths = []string{filepath.Join(root, "host", "compose-plugin")} + legacyUnattendedPaths = []string{filepath.Join(root, "host", "legacy-unattended")} + t.Cleanup(func() { composePluginPaths, legacyUnattendedPaths = oldPlugins, oldLegacy }) + home := filepath.Join(root, "home") + if err := os.MkdirAll(filepath.Join(home, ".rootfiles"), 0755); err != nil { + t.Fatal(err) + } + for _, key := range []string{"ROOTFILES_HOME_BASE", "ROOTFILES_TIMEZONE", "ROOTFILES_TUNNEL_TOKEN", "ROOTFILES_VLAN_ADDRESS", "ROOTFILES_VLAN_INTERFACE", "ROOTFILES_DOCKER_ROOT", "ROOTFILES_DATA_DIR"} { + t.Setenv(key, "") + } + t.Setenv("HOME", home) + sys := &config.SystemInfo{OS: "ubuntu", Version: "24.04", Codename: "noble", Arch: "amd64"} + if profile == "rocky" { + sys.OS, sys.Version = "rocky", "9.5" + } + cfg, err := config.LoadWithHomeBase(profile, "", sys, home) + if err != nil { + t.Fatal(err) + } + // These profile values are filesystem targets, not behavior switches. + if cfg.Modules.Docker.StorageDir != "" { + cfg.Modules.Docker.StorageDir = filepath.Join(root, "site", "docker") + } + if cfg.Modules.Storage.DataDir != "" { + cfg.Modules.Storage.DataDir = filepath.Join(root, "site", "data") + } + if len(cfg.Modules.Storage.Symlinks) != 0 { + cfg.Modules.Storage.Symlinks = map[string]string{filepath.Join(root, "site", "data-link"): filepath.Join(root, "site", "data")} + } + write := func(file, text string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(text), 0600); err != nil { + t.Fatal(err) + } + } + write(defaultLocalePath, "LANG="+cfg.Locale+"\n") + write(rockyLocalePath, "LANG="+cfg.Locale+"\n") + write(timezonePath, cfg.Timezone+"\n") + // Keep pre-existing Docker map iteration out of the output contract: only data-root drifts. + write(dockerDaemonJSONPath, `{"log-driver":"json-file"}`) + write(sshdConfigPath, "Include "+sshdConfigDir+"/*.conf\nPort 22\n") + write(useraddDefaultsFile, "HOME="+home+"\n") + write(passwdPath, "root:x:0:0::"+home+":/bin/bash\n") + write(shadowPath, "root:!:20000:0:99999:7:::\n") + bin := filepath.Join(root, "commands") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "dpkg-query": "exit 0", "rpm": "exit 0", "locale-gen": "exit 0", "docker": "exit 0", "getent": "exit 0", + "localectl": "printf 'en_US.UTF-8\\n'", "timedatectl": "printf 'yes\\n'", + "python3": "printf 'rootfiles-updateinfo-ok:appstream,baseos\\n'", + "sshd": "printf 'port 22\\npubkeyauthentication yes\\npasswordauthentication yes\\n'", + "ufw": "printf 'Status: inactive\\n'", "firewall-cmd": "printf 'running\\n'", + "systemctl": "case $1 in is-active) echo inactive; exit 3;; is-enabled) echo disabled; exit 1;; list-unit-files) printf '%s enabled\\n' \"$2\";; esac", + } { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"+body+"\n"), 0755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + runner := guardedTestRunner(t, true, slog.New(slog.NewTextHandler(io.Discard, nil))) + runner.Backup = exec.NewBackup(filepath.Join(root, "backups"), "profile dry-run fixture") + pm, err := exec.NewPackageManager(runner, sys) + if err != nil { + t.Fatal(err) + } + return &RunContext{Config: cfg, Runner: runner, APT: pm, DryRun: true, Yes: true}, root +} + +func outputProfileSnapshot(t *testing.T, root string) map[string]string { + t.Helper() + snapshot := map[string]string{} + for _, managed := range []string{"host", "home", "site", "backups"} { + dir := filepath.Join(root, managed) + if _, err := os.Lstat(dir); os.IsNotExist(err) { + continue + } + if err := filepath.WalkDir(dir, func(file string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + relative, err := filepath.Rel(root, file) + if err != nil { + return err + } + value := info.Mode().String() + if !entry.IsDir() { + data, err := os.ReadFile(file) + if err != nil { + return err + } + value += fmt.Sprintf(" %x", sha256.Sum256(data)) + } + snapshot[relative] = value + return nil + }); err != nil { + t.Fatal(err) + } + } + return snapshot +} diff --git a/internal/module/output_writer_test.go b/internal/module/output_writer_test.go new file mode 100644 index 0000000..d4d7db4 --- /dev/null +++ b/internal/module/output_writer_test.go @@ -0,0 +1,204 @@ +package module + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/STAIxBWLB/rootfiles/internal/ui" +) + +func TestModuleWriterPreservesNamesOnlyPayloads(t *testing.T) { + rc := newDryRunRC(t) + var out bytes.Buffer + rc.Out = &out + path := usersDBPath(rc) + data, err := json.Marshal(UsersDB{Version: 1, Users: []UserMeta{{Name: "alice"}, {Name: "bob"}}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if err := ListUserNames(rc); err != nil { + t.Fatal(err) + } + if got := out.String(); got != "alice\nbob\n" { + t.Fatalf("managed names changed: %q", got) + } + out.Reset() + fixtureSystemUsers(t) + if err := ListSystemUserNames(context.Background(), rc); err != nil { + t.Fatal(err) + } + if got := out.String(); got != "fixture-user\n" { + t.Fatalf("system names changed: %q", got) + } +} + +func TestModuleWriterPreservesRawIDAndQuotaPayloads(t *testing.T) { + fakePasswd(t, "alice") + const idPayload = "uid=1000(alice) groups=1000 100% %s" + const quotaPayload = "blocks 100% %s\n\n indented row" + fakeCommandOutput(t, map[string]string{"id": idPayload, "repquota": quotaPayload}) + rc := newDryRunRC(t) + var out bytes.Buffer + rc.Out = &out + for _, report := range []func(context.Context, *RunContext, string) error{ShowUserID, ListUserGroups} { + out.Reset() + if err := report(context.Background(), rc, "alice"); err != nil { + t.Fatal(err) + } + if got := out.String(); got != idPayload { + t.Fatalf("id payload formatting/newline changed: %q", got) + } + } + mounts := filepath.Join(t.TempDir(), "mounts") + if err := os.WriteFile(mounts, []byte("/dev/fixture "+rc.Config.Users.HomeBase+" ext4 rw,usrquota 0 0\n"), 0644); err != nil { + t.Fatal(err) + } + old := procMountsPath + procMountsPath = mounts + t.Cleanup(func() { procMountsPath = old }) + out.Reset() + if err := ShowQuotas(context.Background(), rc); err != nil { + t.Fatal(err) + } + if got := out.String(); got != quotaPayload { + t.Fatalf("quota payload formatting/newline changed: %q", got) + } +} + +func TestModuleWriterCapturesGPUAndKeyTables(t *testing.T) { + fakePasswd(t, "alice") + rc := newDryRunRC(t) + var out bytes.Buffer + rc.Out = &out + setupDBWithAllocations(t, rc.Config.Users.HomeBase, &GPUAllocationsDB{Version: 1, TotalGPUs: 3, Allocations: []GPUAllocation{{Username: "alice", GPUs: []int{0, 2}, Method: "env", UpdatedAt: "fixture-time"}}}) + if err := ListGPUAllocations(rc); err != nil { + t.Fatal(err) + } + for _, field := range []string{"GPU Allocations", "USER", "GPUs", "alice", "0,2", "fixture-time"} { + if !strings.Contains(out.String(), field) { + t.Fatalf("GPU table lost %q from injected writer: %q", field, out.String()) + } + } + out.Reset() + if err := ListKeys(rc, "alice"); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "ssh-ed25519 AAAA") || !strings.Contains(out.String(), "test") { + t.Fatalf("key table bypassed injected writer: %q", out.String()) + } +} + +func TestModuleWriterCapturesCloudflaredReportsWithoutMutation(t *testing.T) { + fakeCloudflaredPaths(t) + const version = "cloudflared version 2026.11.10 writer 100% %s" + if err := os.WriteFile(cloudflaredBinary, []byte("#!/bin/sh\nprintf '%s\\n' '"+version+"'\n"), 0755); err != nil { + t.Fatal(err) + } + fakeCommandOutput(t, map[string]string{"systemctl": "active\n", "ip": " inet 203.0.113.2/32 scope global vlan0\n"}) + rc := newDryRunRC(t) + var out bytes.Buffer + rc.Out = &out + if err := TunnelStatus(context.Background(), rc); err != nil { + t.Fatal(err) + } + for _, field := range []string{"Tunnel", version, "active", "203.0.113.2/32"} { + if !strings.Contains(out.String(), field) { + t.Fatalf("tunnel report lost %q from injected writer: %q", field, out.String()) + } + } + out.Reset() + if err := TunnelUpdate(context.Background(), rc, "2026.11.10"); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Cloudflared update") || !strings.Contains(out.String(), "already on target version") { + t.Fatalf("no-op update report bypassed writer: %q", out.String()) + } +} + +func TestModuleWriterKeepsMetadataWarningsOnSeparateStream(t *testing.T) { + rc := newDryRunRC(t) + var out, warnings bytes.Buffer + rc.Out = &out + old := warnOut + warnOut = &warnings + t.Cleanup(func() { warnOut = old }) + if err := os.Chmod(filepath.Join(rc.Config.Users.HomeBase, ".rootfiles"), 0777); err != nil { + t.Fatal(err) + } + WarnUntrustedMetadata(rc) + if out.Len() != 0 || !strings.Contains(warnings.String(), "untrusted rootfiles metadata") || !strings.HasPrefix(warnings.String(), ui.MarkWarn+" ") { + t.Fatalf("warning stream changed: stdout=%q warnings=%q", out.String(), warnings.String()) + } +} + +func TestModuleWriterCapturesForcedSSHWarning(t *testing.T) { + fakePasswd(t) + withPasswords(t, "alice") + fakeBin(t, "sshd", "systemctl") + rc := newDryRunRC(t) + rc.Force = true + rc.Config.SSH.DisablePasswordAuth = true + var out bytes.Buffer + rc.Out = &out + old := sshdDropInPath + sshdDropInPath = filepath.Join(t.TempDir(), "drop-in.conf") + t.Cleanup(func() { sshdDropInPath = old }) + if _, err := NewSSHModule().Apply(context.Background(), rc); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), ui.MarkWarn+" ssh:") || !strings.Contains(out.String(), "continuing because of --force") { + t.Fatalf("force warning bypassed injected writer: %q", out.String()) + } +} + +func TestModuleMachineChangeMarkersRemainUnstyledBytes(t *testing.T) { + rc := newDryRunRC(t) + link, target := filepath.Join(t.TempDir(), "link"), filepath.Join(t.TempDir(), "target") + rc.Config.Modules.Storage.Symlinks = map[string]string{link: target} + storage, err := NewStorageModule().Check(context.Background(), rc) + if err != nil { + t.Fatal(err) + } + want := "Create symlink " + link + " " + ui.MarkPending + " " + target + found := false + for _, change := range storage.Changes { + found = found || change.Description == want + } + if !found { + t.Fatalf("machine storage description changed: %+v", storage.Changes) + } + old := cloudflaredBinary + cloudflaredBinary = filepath.Join(t.TempDir(), "missing-cloudflared") + t.Cleanup(func() { cloudflaredBinary = old }) + cloud, err := NewCloudflaredModule().Check(context.Background(), rc) + if err != nil { + t.Fatal(err) + } + if len(cloud.Changes) != 1 || cloud.Changes[0].Command != "download cloudflared "+ui.MarkPending+" "+cloudflaredBinary { + t.Fatalf("machine cloudflared command changed: %+v", cloud.Changes) + } +} + +func TestNewTenantManagerHonorsContextAndExplicitWriters(t *testing.T) { + rc := newDryRunRC(t) + var contextOut, explicitOut bytes.Buffer + rc.Out = &contextOut + if manager := NewTenantManager(rc, nil); manager.Out != &contextOut { + t.Fatal("nil manager writer did not inherit context output") + } + if manager := NewTenantManager(rc, &explicitOut); manager.Out != &explicitOut { + t.Fatal("explicit manager writer was replaced") + } + if manager := NewTenantManager(rc, io.Discard); manager.Out != io.Discard { + t.Fatal("explicit discard writer was replaced") + } +} diff --git a/internal/module/quota.go b/internal/module/quota.go index 948e100..5221728 100644 --- a/internal/module/quota.go +++ b/internal/module/quota.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/STAIxBWLB/rootfiles/internal/ui" ) // Per-user disk quotas on the home_base filesystem. XFS uses project @@ -136,9 +138,9 @@ func SetQuota(ctx context.Context, rc *RunContext, username, size string) error return err } if kib == 0 { - fmt.Printf("quota removed for %s\n", username) + ui.WriteText(rc.Output(), "quota removed for %s\n", username) } else { - fmt.Printf("quota for %s set to %s on %s (%s)\n", username, size, mnt.Point, mnt.FSType) + ui.WriteText(rc.Output(), "quota for %s set to %s on %s (%s)\n", username, size, mnt.Point, mnt.FSType) } return nil } @@ -167,7 +169,7 @@ func ShowQuotas(ctx context.Context, rc *RunContext) error { default: return fmt.Errorf("quotas are supported on xfs and ext4; %s is %s", mnt.Point, mnt.FSType) } - fmt.Print(out) + ui.WriteRaw(rc.Output(), out) return nil } @@ -180,6 +182,6 @@ func reapplyQuota(ctx context.Context, rc *RunContext, m UserMeta) { return } if err := SetQuota(ctx, rc, m.Name, m.Quota); err != nil { - fmt.Printf(" ⚠ %s: quota %s not restored: %v\n", m.Name, m.Quota, err) + ui.WriteText(rc.Output(), " "+ui.MarkWarn+" %s: quota %s not restored: %v\n", m.Name, m.Quota, err) } } diff --git a/internal/module/run_context_output_test.go b/internal/module/run_context_output_test.go new file mode 100644 index 0000000..4ce1037 --- /dev/null +++ b/internal/module/run_context_output_test.go @@ -0,0 +1,36 @@ +package module + +import ( + "bytes" + "context" + "os" + "strings" + "testing" +) + +func TestRunContextOutputKeepsNilFallback(t *testing.T) { + rc := &RunContext{} + if rc.Output() != os.Stdout { + t.Fatal("nil output no longer defaults to stdout") + } + var out bytes.Buffer + rc.Out = &out + if rc.Output() != &out { + t.Fatal("explicit writer was ignored") + } +} + +func TestRunAllUsesContextWriter(t *testing.T) { + rc := newDryRunRC(t) + var out bytes.Buffer + rc.Out = &out + result, err := RunAll(context.Background(), []Module{stubModule{name: "pending"}, stubModule{name: "ready", satisfied: true}}, rc) + if err != nil || len(result) != 2 { + t.Fatalf("outcomes=%v error=%v", result, err) + } + for _, want := range []string{"pending: stub", "ready: already satisfied"} { + if !strings.Contains(out.String(), want) { + t.Errorf("explicit writer missing %q: %q", want, out.String()) + } + } +} diff --git a/internal/module/ssh.go b/internal/module/ssh.go index 6d7c459..f98989f 100644 --- a/internal/module/ssh.go +++ b/internal/module/ssh.go @@ -12,6 +12,7 @@ import ( "github.com/STAIxBWLB/rootfiles/internal/config" "github.com/STAIxBWLB/rootfiles/internal/exec" + "github.com/STAIxBWLB/rootfiles/internal/ui" ) type SSHModule struct{} @@ -157,7 +158,7 @@ func (m *SSHModule) Apply(ctx context.Context, rc *RunContext) (*ApplyResult, er if !rc.Force { return nil, fmt.Errorf("%w (re-run with --force to override)", err) } - fmt.Printf(" ⚠ ssh: %v — continuing because of --force\n", err) + ui.WriteText(rc.Output(), " "+ui.MarkWarn+" ssh: %v — continuing because of --force\n", err) } } diff --git a/internal/module/storage.go b/internal/module/storage.go index cc7e5f9..1d2e296 100644 --- a/internal/module/storage.go +++ b/internal/module/storage.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/STAIxBWLB/rootfiles/internal/ui" ) type StorageModule struct{} @@ -52,7 +54,7 @@ func (m *StorageModule) Check(_ context.Context, rc *RunContext) (*CheckResult, if isSymlinkTo(link, target) { continue } - desc := fmt.Sprintf("Create symlink %s → %s", link, target) + desc := fmt.Sprintf("Create symlink %s "+ui.MarkPending+" %s", link, target) if err := symlinkBlocker(link); err != nil { desc += fmt.Sprintf(" (blocked: %v)", err) } @@ -118,7 +120,7 @@ func (m *StorageModule) Apply(ctx context.Context, rc *RunContext) (*ApplyResult continue } if err := symlinkBlocker(link); err != nil { - return nil, fmt.Errorf("cannot create symlink %s → %s: %w", link, target, err) + return nil, fmt.Errorf("cannot create symlink %s "+ui.MarkPending+" %s: %w", link, target, err) } if err := rc.Runner.MkdirAll(target, 0755); err != nil { return nil, fmt.Errorf("creating symlink target %s: %w", target, err) @@ -129,9 +131,9 @@ func (m *StorageModule) Apply(ctx context.Context, rc *RunContext) (*ApplyResult } } if err := rc.Runner.Symlink(target, link); err != nil { - return nil, fmt.Errorf("creating symlink %s → %s: %w", link, target, err) + return nil, fmt.Errorf("creating symlink %s "+ui.MarkPending+" %s: %w", link, target, err) } - messages = append(messages, fmt.Sprintf("symlink %s → %s", link, target)) + messages = append(messages, fmt.Sprintf("symlink %s "+ui.MarkPending+" %s", link, target)) changed = true } diff --git a/internal/module/tenant.go b/internal/module/tenant.go index 59473fb..67d2cc6 100644 --- a/internal/module/tenant.go +++ b/internal/module/tenant.go @@ -35,6 +35,9 @@ var tenantTimers = []string{tenantGuardUnit + ".timer", tenantSyncUnit + ".timer // NewTenantManager returns a tenant manager wired to rc's runner, config and // the shared GPU ledger. func NewTenantManager(rc *RunContext, out io.Writer) *tenant.Manager { + if out == nil { + out = rc.Output() + } return &tenant.Manager{ Runner: rc.Runner, Root: rc.Config.Modules.Tenant.DataRoot(), diff --git a/internal/module/testdata/output-t4/base.after.golden b/internal/module/testdata/output-t4/base.after.golden new file mode 100644 index 0000000..66d9188 --- /dev/null +++ b/internal/module/testdata/output-t4/base.after.golden @@ -0,0 +1,3 @@ + ✓ locale: already satisfied + → packages: Install 18 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common] + → ssh: Deploy custom sshd configuration diff --git a/internal/module/testdata/output-t4/base.before.golden b/internal/module/testdata/output-t4/base.before.golden new file mode 100644 index 0000000..52ccfd7 --- /dev/null +++ b/internal/module/testdata/output-t4/base.before.golden @@ -0,0 +1,3 @@ + ✓ locale: already satisfied + → packages: Install 18 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common] + → ssh: Deploy custom sshd configuration diff --git a/internal/module/testdata/output-t4/dgx.after.golden b/internal/module/testdata/output-t4/dgx.after.golden new file mode 100644 index 0000000..293c9b9 --- /dev/null +++ b/internal/module/testdata/output-t4/dgx.after.golden @@ -0,0 +1,21 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Set Docker daemon data-root = /site/docker + → docker: Enable and start Docker service + → docker: Restart Docker after daemon configuration changes + → nvidia: Install NVIDIA Container Toolkit + → nvidia: Configure Docker nvidia runtime + → nvidia: Enable nvidia-persistenced + → nvidia: Enable nvidia-fabricmanager + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + → storage: Create data directory /site/data + → storage: Create symlink /site/data-link → /site/data + → storage: Create Docker storage directory /site/docker + ✗ network: blocked: Firewall verification (blocked: firewalld is active; choose one firewall manager and disable modules.network.ufw or stop firewalld through the host firewall change process before applying) diff --git a/internal/module/testdata/output-t4/dgx.before.golden b/internal/module/testdata/output-t4/dgx.before.golden new file mode 100644 index 0000000..afbf18b --- /dev/null +++ b/internal/module/testdata/output-t4/dgx.before.golden @@ -0,0 +1,21 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Set Docker daemon data-root = /site/docker + → docker: Enable and start Docker service + → docker: Restart Docker after daemon configuration changes + → nvidia: Install NVIDIA Container Toolkit + → nvidia: Configure Docker nvidia runtime + → nvidia: Enable nvidia-persistenced + → nvidia: Enable nvidia-fabricmanager + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + → storage: Create data directory /site/data + → storage: Create symlink /site/data-link → /site/data + → storage: Create Docker storage directory /site/docker + ✗ network: blocked: Firewall verification (blocked: firewalld is active; choose one firewall manager and disable modules.network.ufw or stop firewalld through the host firewall change process before applying) diff --git a/internal/module/testdata/output-t4/full.after.golden b/internal/module/testdata/output-t4/full.after.golden new file mode 100644 index 0000000..d58345c --- /dev/null +++ b/internal/module/testdata/output-t4/full.after.golden @@ -0,0 +1,11 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Enable and start Docker service + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied diff --git a/internal/module/testdata/output-t4/full.before.golden b/internal/module/testdata/output-t4/full.before.golden new file mode 100644 index 0000000..3a8dfbd --- /dev/null +++ b/internal/module/testdata/output-t4/full.before.golden @@ -0,0 +1,11 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Enable and start Docker service + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied diff --git a/internal/module/testdata/output-t4/gpu-server.after.golden b/internal/module/testdata/output-t4/gpu-server.after.golden new file mode 100644 index 0000000..3d9246e --- /dev/null +++ b/internal/module/testdata/output-t4/gpu-server.after.golden @@ -0,0 +1,16 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Enable and start Docker service + → nvidia: Install NVIDIA Container Toolkit + → nvidia: Configure Docker nvidia runtime + → nvidia: Enable nvidia-persistenced + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied + ✗ network: blocked: Firewall verification (blocked: firewalld is active; choose one firewall manager and disable modules.network.ufw or stop firewalld through the host firewall change process before applying) diff --git a/internal/module/testdata/output-t4/gpu-server.before.golden b/internal/module/testdata/output-t4/gpu-server.before.golden new file mode 100644 index 0000000..0eb5163 --- /dev/null +++ b/internal/module/testdata/output-t4/gpu-server.before.golden @@ -0,0 +1,16 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration (blocked: disabling password auth and root login, but no non-root account has an SSH authorized key) + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → docker: Enable and start Docker service + → nvidia: Install NVIDIA Container Toolkit + → nvidia: Configure Docker nvidia runtime + → nvidia: Enable nvidia-persistenced + ✓ gpu: already satisfied + → cloudflared: Install cloudflared binary + ✓ storage: already satisfied + ✗ network: blocked: Firewall verification (blocked: firewalld is active; choose one firewall manager and disable modules.network.ufw or stop firewalld through the host firewall change process before applying) diff --git a/internal/module/testdata/output-t4/minimal.after.golden b/internal/module/testdata/output-t4/minimal.after.golden new file mode 100644 index 0000000..997e6d5 --- /dev/null +++ b/internal/module/testdata/output-t4/minimal.after.golden @@ -0,0 +1,9 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → cloudflared: Install cloudflared binary diff --git a/internal/module/testdata/output-t4/minimal.before.golden b/internal/module/testdata/output-t4/minimal.before.golden new file mode 100644 index 0000000..f985c8a --- /dev/null +++ b/internal/module/testdata/output-t4/minimal.before.golden @@ -0,0 +1,9 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 21 packages: [ca-certificates curl wget gnupg lsb-release git git-lfs vim tmux tree jq htop unzip zip build-essential locales zsh software-properties-common openssh-server ufw fail2ban] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration + → security: Install [unattended-upgrades fail2ban python3-systemd systemd-timesyncd] + → security: Enable security-only unattended upgrades (no reboot, NVIDIA stack excluded) + → security: Configure fail2ban sshd jail + → cloudflared: Install cloudflared binary diff --git a/internal/module/testdata/output-t4/rocky.after.golden b/internal/module/testdata/output-t4/rocky.after.golden new file mode 100644 index 0000000..a03d677 --- /dev/null +++ b/internal/module/testdata/output-t4/rocky.after.golden @@ -0,0 +1,9 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 19 packages: [ca-certificates curl wget gnupg2 git vim-enhanced tmux tree jq unzip zip gcc gcc-c++ make glibc-langpack-en zsh openssh-server chrony dnf-automatic] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration + → security: Install [dnf-automatic chrony] + → security: Configure security-only automatic updates for Rocky baseos/appstream (no reboot, GPU packages excluded) + → security: Enable DNF automatic security update timer + → security: Enable chronyd time synchronization diff --git a/internal/module/testdata/output-t4/rocky.before.golden b/internal/module/testdata/output-t4/rocky.before.golden new file mode 100644 index 0000000..b3bb3ba --- /dev/null +++ b/internal/module/testdata/output-t4/rocky.before.golden @@ -0,0 +1,9 @@ + ✓ locale: already satisfied + → system: Cap journald at 2G + → packages: Install 19 packages: [ca-certificates curl wget gnupg2 git vim-enhanced tmux tree jq unzip zip gcc gcc-c++ make glibc-langpack-en zsh openssh-server chrony dnf-automatic] + ✓ users: already satisfied + → ssh: Deploy custom sshd configuration + → security: Install [dnf-automatic chrony] + → security: Configure security-only automatic updates for Rocky baseos/appstream (no reboot, GPU packages excluded) + → security: Enable DNF automatic security update timer + → security: Enable chronyd time synchronization diff --git a/internal/module/users.go b/internal/module/users.go index 1de6b82..0b7563a 100644 --- a/internal/module/users.go +++ b/internal/module/users.go @@ -17,6 +17,7 @@ import ( "time" "github.com/STAIxBWLB/rootfiles/internal/config" + "github.com/STAIxBWLB/rootfiles/internal/ui" ) type UsersModule struct{} @@ -142,7 +143,7 @@ func rootOnlyTree(path string, uid uint32) error { // WarnUntrustedMetadata lets a read-only report go on, naming the reason. func WarnUntrustedMetadata(rc *RunContext) { if err := requireTrustedMetadata(rc); err != nil { - fmt.Fprintf(warnOut, "⚠ untrusted rootfiles metadata (%v); this report reads it anyway\n", err) + ui.WriteText(warnOut, ui.MarkWarn+" untrusted rootfiles metadata (%v); this report reads it anyway\n", err) } } @@ -436,7 +437,7 @@ func AddUser(ctx context.Context, rc *RunContext, username string, pubkeys []str shell = "/usr/bin/zsh" } if !rc.DryRun && !rc.Runner.FileExists(shell) { - fmt.Printf(" %s shell %s not found, using /bin/bash\n", "⚠", shell) + ui.WriteText(rc.Output(), " %s shell %s not found, using /bin/bash\n", ui.MarkWarn, shell) shell = "/bin/bash" } @@ -461,7 +462,7 @@ func AddUser(ctx context.Context, rc *RunContext, username string, pubkeys []str } groups, missing := existingGroups(groups) for _, g := range missing { - fmt.Printf(" ⚠ group %s does not exist yet, skipped (add later with 'rootfiles user group-add')\n", g) + ui.WriteText(rc.Output(), " "+ui.MarkWarn+" group %s does not exist yet, skipped (add later with 'rootfiles user group-add')\n", g) } // Create user @@ -493,7 +494,7 @@ func AddUser(ctx context.Context, rc *RunContext, username string, pubkeys []str return fmt.Errorf("recording user metadata: %w", err) } - fmt.Printf("User %s created (home: %s, shell: %s, groups: %v)\n", username, homeDir, shell, groups) + ui.WriteText(rc.Output(), "User %s created (home: %s, shell: %s, groups: %v)\n", username, homeDir, shell, groups) return nil } @@ -508,7 +509,7 @@ func BackupUsers(rc *RunContext, outputPath string) error { var db UsersDB dbPath := filepath.Join(homeBase, ".rootfiles", "users.json") if err := requireTrustedMetadata(rc); err != nil { - fmt.Fprintf(warnOut, "⚠ %v; leaving the users database out of the backup\n", err) + ui.WriteText(warnOut, ui.MarkWarn+" %v; leaving the users database out of the backup\n", err) } else if data, err := rc.Runner.ReadFile(dbPath); err == nil { json.Unmarshal(data, &db) } @@ -516,7 +517,7 @@ func BackupUsers(rc *RunContext, outputPath string) error { // Scan system users and merge sysUsers, err := scanSystemUsers(ctx, rc) if err != nil { - fmt.Printf(" Warning: system user scan failed: %v\n", err) + ui.WriteText(rc.Output(), " Warning: system user scan failed: %v\n", err) } managed := make(map[string]bool) @@ -553,7 +554,7 @@ func BackupUsers(rc *RunContext, outputPath string) error { return fmt.Errorf("writing backup: %w", err) } - fmt.Printf("User backup saved to %s (%d users)\n", outputPath, len(db.Users)) + ui.WriteText(rc.Output(), "User backup saved to %s (%d users)\n", outputPath, len(db.Users)) return nil } @@ -674,7 +675,7 @@ func RestoreUsers(ctx context.Context, rc *RunContext, backupPath string) error } // Check if user already exists if _, err := lookupAccount(u.Name); err == nil { - fmt.Printf(" User %s already exists, skipping\n", u.Name) + ui.WriteText(rc.Output(), " User %s already exists, skipping\n", u.Name) continue } @@ -713,7 +714,7 @@ func RestoreUsers(ctx context.Context, rc *RunContext, backupPath string) error if homeExists { status = "restored (home preserved)" } - fmt.Printf(" User %s %s\n", u.Name, status) + ui.WriteText(rc.Output(), " User %s %s\n", u.Name, status) } if len(failed) > 0 { @@ -731,7 +732,7 @@ func restoreUserExtras(ctx context.Context, rc *RunContext, u UserMeta, homeExis } groups, missing := existingGroups(groups) for _, g := range missing { - fmt.Printf(" ⚠ %s: group %s does not exist, skipped\n", u.Name, g) + ui.WriteText(rc.Output(), " "+ui.MarkWarn+" %s: group %s does not exist, skipped\n", u.Name, g) } if len(groups) > 0 { if _, err := rc.Runner.Run(ctx, "usermod", "-aG", strings.Join(groups, ","), u.Name); err != nil { @@ -832,10 +833,10 @@ func RehomeUser(ctx context.Context, rc *RunContext, username string, removeOld return fmt.Errorf("removing old home backup %s: %w", backup, err) } } else { - fmt.Printf("Original home kept at %s (delete it once verified)\n", backup) + ui.WriteText(rc.Output(), "Original home kept at %s (delete it once verified)\n", backup) } - fmt.Printf("User %s moved: %s → %s (symlink created)\n", username, oldHome, newHome) + ui.WriteText(rc.Output(), "User %s moved: %s "+ui.MarkPending+" %s (symlink created)\n", username, oldHome, newHome) return nil } @@ -848,7 +849,7 @@ func ListUsers(rc *RunContext) error { dbPath := filepath.Join(homeBase, ".rootfiles", "users.json") data, err := rc.Runner.ReadFile(dbPath) if err != nil { - fmt.Println("No managed users found.") + ui.WriteLine(rc.Output(), "No managed users found.") return nil } @@ -857,11 +858,11 @@ func ListUsers(rc *RunContext) error { return fmt.Errorf("parsing user database: %w", err) } - fmt.Printf("Home base: %s\n", db.HomeBase) - fmt.Printf("%-15s %-6s %-20s %-15s %s\n", "USER", "UID", "HOME", "SHELL", "GROUPS") - fmt.Printf("%-15s %-6s %-20s %-15s %s\n", "----", "---", "----", "-----", "------") + ui.WriteText(rc.Output(), "Home base: %s\n", db.HomeBase) + ui.WriteText(rc.Output(), "%-15s %-6s %-20s %-15s %s\n", "USER", "UID", "HOME", "SHELL", "GROUPS") + ui.WriteText(rc.Output(), "%-15s %-6s %-20s %-15s %s\n", "----", "---", "----", "-----", "------") for _, u := range db.Users { - fmt.Printf("%-15s %-6d %-20s %-15s %s\n", + ui.WriteText(rc.Output(), "%-15s %-6d %-20s %-15s %s\n", u.Name, u.UID, u.Home, u.Shell, strings.Join(u.Groups, ",")) } return nil @@ -884,7 +885,7 @@ func ListUserNames(rc *RunContext) error { } for _, u := range db.Users { - fmt.Println(u.Name) + ui.WriteLine(rc.Output(), u.Name) } return nil } @@ -896,13 +897,13 @@ func ListSystemUsers(ctx context.Context, rc *RunContext) error { return fmt.Errorf("scanning system users: %w", err) } if len(users) == 0 { - fmt.Println("No system users found.") + ui.WriteLine(rc.Output(), "No system users found.") return nil } - fmt.Printf("%-15s %-6s %-6s %-20s %-15s %s\n", "USER", "UID", "GID", "HOME", "SHELL", "GROUPS") - fmt.Printf("%-15s %-6s %-6s %-20s %-15s %s\n", "----", "---", "---", "----", "-----", "------") + ui.WriteText(rc.Output(), "%-15s %-6s %-6s %-20s %-15s %s\n", "USER", "UID", "GID", "HOME", "SHELL", "GROUPS") + ui.WriteText(rc.Output(), "%-15s %-6s %-6s %-20s %-15s %s\n", "----", "---", "---", "----", "-----", "------") for _, u := range users { - fmt.Printf("%-15s %-6d %-6d %-20s %-15s %s\n", + ui.WriteText(rc.Output(), "%-15s %-6d %-6d %-20s %-15s %s\n", u.Name, u.UID, u.GID, u.Home, u.Shell, strings.Join(u.Groups, ",")) } return nil @@ -915,7 +916,7 @@ func ListSystemUserNames(ctx context.Context, rc *RunContext) error { return fmt.Errorf("scanning system users: %w", err) } for _, u := range users { - fmt.Println(u.Name) + ui.WriteLine(rc.Output(), u.Name) } return nil } @@ -929,7 +930,7 @@ func ShowUserID(ctx context.Context, rc *RunContext, username string) error { if err != nil { return fmt.Errorf("querying id for %s: %w", username, err) } - fmt.Print(result.Stdout) + ui.WriteRaw(rc.Output(), result.Stdout) return nil } @@ -939,8 +940,8 @@ func ListGroups(ctx context.Context, rc *RunContext) error { if err != nil { return fmt.Errorf("reading /etc/group: %w", err) } - fmt.Printf("%-20s %-6s %s\n", "GROUP", "GID", "MEMBERS") - fmt.Printf("%-20s %-6s %s\n", "-----", "---", "-------") + ui.WriteText(rc.Output(), "%-20s %-6s %s\n", "GROUP", "GID", "MEMBERS") + ui.WriteText(rc.Output(), "%-20s %-6s %s\n", "-----", "---", "-------") scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := scanner.Text() @@ -954,7 +955,7 @@ func ListGroups(ctx context.Context, rc *RunContext) error { name := fields[0] gid := fields[2] members := fields[3] - fmt.Printf("%-20s %-6s %s\n", name, gid, members) + ui.WriteText(rc.Output(), "%-20s %-6s %s\n", name, gid, members) } return nil } @@ -968,7 +969,7 @@ func ListUserGroups(ctx context.Context, rc *RunContext, username string) error if err != nil { return fmt.Errorf("querying id for %s: %w", username, err) } - fmt.Print(result.Stdout) + ui.WriteRaw(rc.Output(), result.Stdout) return nil } @@ -987,7 +988,7 @@ func AddUserToGroups(ctx context.Context, rc *RunContext, username string, group if _, err := rc.Runner.Run(ctx, "usermod", "-aG", strings.Join(groups, ","), username); err != nil { return fmt.Errorf("adding %s to groups: %w", username, err) } - fmt.Printf("User %s added to groups: %s\n", username, strings.Join(groups, ", ")) + ui.WriteText(rc.Output(), "User %s added to groups: %s\n", username, strings.Join(groups, ", ")) return nil } @@ -1005,9 +1006,9 @@ func RemoveUserFromGroups(ctx context.Context, rc *RunContext, username string, } for _, group := range groups { if _, err := rc.Runner.Run(ctx, "gpasswd", "-d", username, group); err != nil { - fmt.Printf(" Failed to remove %s from %s: %v\n", username, group, err) + ui.WriteText(rc.Output(), " Failed to remove %s from %s: %v\n", username, group, err) } else { - fmt.Printf(" Removed %s from %s\n", username, group) + ui.WriteText(rc.Output(), " Removed %s from %s\n", username, group) } } return nil @@ -1040,25 +1041,25 @@ func SetPasswords(ctx context.Context, rc *RunContext, entries []PasswordEntry, } } if rc.DryRun { - fmt.Printf(" [dry-run] set password for %s (password: ****)\n", e.Username) + ui.WriteText(rc.Output(), " [dry-run] set password for %s (password: ****)\n", e.Username) continue } if err := setPassword(ctx, rc, e.Username, pass); err != nil { - fmt.Printf(" Failed to set password for %s: %s\n", e.Username, firstLine(err.Error())) + ui.WriteText(rc.Output(), " Failed to set password for %s: %s\n", e.Username, firstLine(err.Error())) failed = append(failed, e.Username) continue } if expire { if _, err := rc.Runner.Run(ctx, "chage", "-d", "0", e.Username); err != nil { - fmt.Printf(" Password set for %s, but expiring it failed: %s\n", e.Username, firstLine(err.Error())) + ui.WriteText(rc.Output(), " Password set for %s, but expiring it failed: %s\n", e.Username, firstLine(err.Error())) failed = append(failed, e.Username) continue } } if generated != "" { - fmt.Printf(" Password set for %s: %s\n", e.Username, generated) + ui.WriteText(rc.Output(), " Password set for %s: %s\n", e.Username, generated) } else { - fmt.Printf(" Password set for %s\n", e.Username) + ui.WriteText(rc.Output(), " Password set for %s\n", e.Username) } } if len(failed) > 0 { diff --git a/internal/tenant/import.go b/internal/tenant/import.go index cc27951..f06c5cc 100644 --- a/internal/tenant/import.go +++ b/internal/tenant/import.go @@ -9,6 +9,8 @@ import ( "sort" "strconv" "strings" + + "github.com/STAIxBWLB/rootfiles/internal/ui" ) // ImportOptions describe moving a hand-made container under rootfiles. @@ -163,7 +165,7 @@ func (m *Manager) Import(ctx context.Context, o ImportOptions) (*Spec, error) { m.printf(" copy %s -> %s\n", it.src, it.dst) } for _, n := range notes { - m.printf(" ⚠ %s\n", n) + m.printf(" "+ui.MarkWarn+" %s\n", n) } m.printf(" data to copy %s, volume %s, free on %s %s\n", human(total), human(size), m.Root, human(free)) if total > size*95/100 { @@ -222,7 +224,7 @@ func (m *Manager) Import(ctx context.Context, o ImportOptions) (*Spec, error) { _ = m.run(ctx, "docker", "start", o.From) return nil, fmt.Errorf("%w; %s was restarted, the copied volume and spec are kept for another try", err, o.From) } - m.printf("✓ tenant %s imported; %s is stopped and kept for rollback until you remove it\n", s.Name, o.From) + m.printf(ui.MarkOK+" tenant %s imported; %s is stopped and kept for rollback until you remove it\n", s.Name, o.From) return s, nil } diff --git a/internal/tenant/manager.go b/internal/tenant/manager.go index 9839f68..41e76a2 100644 --- a/internal/tenant/manager.go +++ b/internal/tenant/manager.go @@ -17,6 +17,7 @@ import ( "time" "github.com/STAIxBWLB/rootfiles/internal/exec" + "github.com/STAIxBWLB/rootfiles/internal/ui" ) // GuardPath is the GPU device guard the host-prep module installs. @@ -240,7 +241,7 @@ func (m *Manager) recreate(ctx context.Context, s *Spec, st containerState) erro } } if size := m.writableSize(ctx, name); size != "" { - m.printf("⚠ %s writable layer holds %s; files outside /data, /home and /tmp stay in %s, not in the new container\n", name, size, prev) + m.printf(ui.MarkWarn+" %s writable layer holds %s; files outside /data, /home and /tmp stay in %s, not in the new container\n", name, size, prev) } m.printf("Recreating %s (image %s)\n", name, s.Image) steps := [][]string{ @@ -265,7 +266,7 @@ func (m *Manager) recreate(ctx context.Context, s *Spec, st containerState) erro err = m.Verify(ctx, s) } if err != nil { - m.printf("✗ new container failed (%v); rolling back\n", firstLine(err.Error())) + m.printf(ui.MarkFail+" new container failed (%v); rolling back\n", firstLine(err.Error())) _ = m.run(ctx, "docker", "rm", "-f", name) for _, a := range [][]string{{"rename", prev, name}, {"update", "--restart=" + orDefault(st.Restart, "always"), name}, {"start", name}} { if rerr := m.run(ctx, "docker", a...); rerr != nil { @@ -328,7 +329,7 @@ func (m *Manager) Verify(ctx context.Context, s *Spec) error { if len(s.GPUs) > 0 { what += " and GPUs " + intList(s.GPUs) } - m.printf("✓ %s: %s verified on port %d\n", ContainerName(s.Name), what, s.SSHPort) + m.printf(ui.MarkOK+" %s: %s verified on port %d\n", ContainerName(s.Name), what, s.SSHPort) return nil } @@ -352,7 +353,7 @@ func sshBanner(addr string) error { func (m *Manager) checkHostKey(ctx context.Context, s *Spec) error { pub, err := os.ReadFile(filepath.Join(m.Paths(s.Name).System, "ssh", "ssh_host_ed25519_key.pub")) if err != nil || !m.Runner.CommandExists("ssh-keyscan") { - m.printf("⚠ host key not compared (no stored ed25519 key or no ssh-keyscan)\n") + m.printf(ui.MarkWarn + " host key not compared (no stored ed25519 key or no ssh-keyscan)\n") return nil } want := strings.Fields(string(pub)) @@ -658,9 +659,9 @@ func (m *Manager) Status(ctx context.Context, names []string, detail bool) error } if len(specs) > 0 { c := m.commitment(specs) - mark := "✓" + mark := ui.MarkOK if c.Overcommitted() { - mark = "⚠ overcommitted:" + mark = ui.MarkWarn + " overcommitted:" } fmt.Fprintf(m.Out, "%s volumes commit %s, %s allocated, %s free on %s\n", mark, human(c.Committed), human(c.Allocated), human(c.Free), m.Root) } diff --git a/internal/tenant/volume.go b/internal/tenant/volume.go index a2c42fe..5e5cfd7 100644 --- a/internal/tenant/volume.go +++ b/internal/tenant/volume.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/STAIxBWLB/rootfiles/internal/ui" ) // EnsureVolume creates the tenant volume image, its mount unit and the @@ -71,7 +73,7 @@ func (m *Manager) EnsureVolume(ctx context.Context, s *Spec) error { case size > cur: return m.grow(ctx, s, size) case size < cur: - m.printf("⚠ %s volume is %s, larger than the spec's %s; shrink it with `rootfiles tenant volume resize %s %s --offline`\n", + m.printf(ui.MarkWarn+" %s volume is %s, larger than the spec's %s; shrink it with `rootfiles tenant volume resize %s %s --offline`\n", s.Name, human(cur), s.Volume.Size, s.Name, s.Volume.Size) } return nil diff --git a/internal/ui/format.go b/internal/ui/format.go index 1b6de92..248cd4a 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -55,3 +55,13 @@ func FirstNonEmpty(values ...string) string { } return "" } + +// WriteText writes formatted plain text without adding styling or a newline. +// Tables and byte-sensitive payloads retain their caller's format. +func WriteText(w io.Writer, format string, args ...any) { fmt.Fprintf(w, format, args...) } + +// WriteLine writes a plain line; an empty argument list writes a blank line. +func WriteLine(w io.Writer, args ...any) { fmt.Fprintln(w, args...) } + +// WriteRaw writes a payload without interpreting format verbs or adding bytes. +func WriteRaw(w io.Writer, text string) { io.WriteString(w, text) } diff --git a/internal/ui/output_boundary_test.go b/internal/ui/output_boundary_test.go new file mode 100644 index 0000000..f9afcdf --- /dev/null +++ b/internal/ui/output_boundary_test.go @@ -0,0 +1,75 @@ +package ui + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// The output boundary is a source contract: machine payloads may use explicit +// fmt.Fprint writers, but native command/module text must never bypass one. +func TestOutputBoundaryHasNoBarePrintsOrExternalGlyphLiterals(t *testing.T) { + err := filepath.WalkDir("..", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "ui" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + if strings.ContainsAny(string(data), MarkOK+MarkFail+MarkWarn+MarkPending) { + t.Errorf("%s has a glyph literal outside ui; use an unstyled ui marker constant", p) + } + parent := filepath.Base(filepath.Dir(p)) + if parent != "module" && parent != "cli" { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), p, data, 0) + if err != nil { + return err + } + fmtName := "" + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + if path == "fmt" { + fmtName = "fmt" + if imp.Name != nil { + fmtName = imp.Name.Name + } + } + } + ast.Inspect(file, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + name, ok := sel.X.(*ast.Ident) + if ok && fmtName != "" && name.Name == fmtName && strings.HasPrefix(sel.Sel.Name, "Print") { + t.Errorf("%s uses stdout-only fmt.%s; pass a context/command writer through a ui helper", p, sel.Sel.Name) + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/ui/text_writer_test.go b/internal/ui/text_writer_test.go new file mode 100644 index 0000000..9de98e6 --- /dev/null +++ b/internal/ui/text_writer_test.go @@ -0,0 +1,17 @@ +package ui + +import ( + "bytes" + "testing" +) + +func TestPlainWriterHelpersKeepPayloadBytes(t *testing.T) { + var out bytes.Buffer + WriteText(&out, "%-5s %d", "name", 7) + WriteLine(&out) + WriteRaw(&out, "100% <&>\n") + WriteLine(&out, "alice") + if got, want := out.String(), "name 7\n100% <&>\nalice\n"; got != want { + t.Fatalf("payload bytes = %q, want %q", got, want) + } +} diff --git a/tests/scenarios/capture-output-routing.py b/tests/scenarios/capture-output-routing.py new file mode 100644 index 0000000..5890a36 --- /dev/null +++ b/tests/scenarios/capture-output-routing.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Capture full CLI previews in a dev container with controlled detectSystem binaries.""" +import argparse +import difflib +import sys +import hashlib +import json +import os +import re +from pathlib import Path +import subprocess +import tempfile + + +def capture(before, after, destination, host_fixture): + if not Path('/.dockerenv').is_file() or os.environ.get('ROOTFILES_OUTPUT_SANDBOX') != '1': + raise SystemExit('output capture requires an isolated dev container; set ROOTFILES_OUTPUT_SANDBOX=1 there') + destination.mkdir(parents=True, exist_ok=True) + host_fixture.mkdir(parents=True, exist_ok=True) + (host_fixture / 'sshd.d').mkdir(exist_ok=True) + (host_fixture / 'sshd_config').write_text('Port 22\n') + # Only data-root drifts, avoiding the existing daemonUpdates map order. + (host_fixture / 'daemon.json').write_text('{"log-driver":"json-file"}\n') + def snapshot(): + return {str(p.relative_to(host_fixture)): ('directory' if p.is_dir() else hashlib.sha256(p.read_bytes()).hexdigest()) for p in host_fixture.rglob('*')} + host_before = snapshot() + with tempfile.TemporaryDirectory(prefix='rootfiles-output-') as temporary: + fixture = Path(temporary) + commands = fixture / 'bin' + commands.mkdir() + # No package queries, NSS or service probes reach tools in the dev image. + bodies = { + 'dpkg-query': 'exit 1', 'rpm': 'exit 1', 'getent': 'exit 0', + 'systemctl': 'exit 1', 'timedatectl': 'exit 1', + 'localectl': "printf 'en_US.UTF-8\\n'", + 'python3': "printf 'rootfiles-updateinfo-ok:appstream,baseos\\n'", + } + for name, body in bodies.items(): + target = commands / name + target.write_text('#!/bin/sh\n' + body + '\n') + target.chmod(0o755) + environment = dict(os.environ) + environment.update(PATH=str(commands), HOME=str(fixture), NO_COLOR='1', TERM='dumb') + for key in list(environment): + if key.startswith('ROOTFILES_'): + environment.pop(key) + environment.update(ROOTFILES_STATE_DIR=str(fixture / 'state'), ROOTFILES_LOCK_FILE=str(fixture / 'lock'), ROOTFILES_LOG_FILE=str(fixture / 'audit')) + records = [] + for profile in ('base', 'minimal', 'dgx', 'gpu-server', 'full', 'rocky'): + pair = [] + for stage, binary in (('before', before), ('after', after)): + profile_environment = dict(environment, ROOTFILES_OUTPUT_DISTRO='rocky' if profile == 'rocky' else 'ubuntu') + run = subprocess.run([str(binary), 'apply', '--profile', profile, '--home-base', '/home', '--yes', '--dry-run'], env=profile_environment, capture_output=True, text=True, timeout=30) + text = run.stdout.replace(str(fixture), '').replace(str(host_fixture), '') + errors = run.stderr.replace(str(fixture), '').replace(str(host_fixture), '') + # Query logs carry the live clock; retain every other stderr byte. + errors = re.sub(r'(?m)^time=[^ ]+ ', 'time= ', errors) + if f'Profile: {profile}\n' not in text or 'Modules: ' not in text: + raise SystemExit(f'{profile}: preview never reached the module plan; inspect the controlled detector fixture: {errors}') + selected = next(line.removeprefix('Modules: ') for line in text.splitlines() if line.startswith('Modules: ')).split(', ') + for name in selected: + if not any(line.startswith(' ') and f'{name}:' in line for line in text.splitlines()): + raise SystemExit(f'{profile}: {name} never reached RunAll; inspect the fixture') + if run.returncode != 0: + raise SystemExit(f'{profile}: controlled preview failed; repair the fixture before comparing output: {errors}\n{text}') + (destination / f'{profile}.{stage}.golden').write_text(text) + records.append(dict(profile=profile, stage=stage, system_fixture=profile_environment['ROOTFILES_OUTPUT_DISTRO'], stderr_clock_normalized=True, exit=run.returncode, stderr=errors)) + pair.append((run.returncode, text, errors)) + # Only reviewed presentation whitespace may differ. Words, marker + # characters, error channel and process result must be unchanged. + if pair[0][0] != pair[1][0] or pair[0][2] != pair[1][2] or pair[0][1].split() != pair[1][1].split(): + print(json.dumps(dict(before_exit=pair[0][0],after_exit=pair[1][0],before_stderr=pair[0][2],after_stderr=pair[1][2]),indent=2),file=sys.stderr) + print(''.join(difflib.unified_diff(pair[0][1].splitlines(True),pair[1][1].splitlines(True),fromfile='before',tofile='after')),file=sys.stderr) + raise SystemExit(f'{profile}: preview changed beyond presentation whitespace; use the printed diff to repair the fixture or output') + for name in ('state', 'lock', 'audit'): + if (fixture / name).exists(): + raise SystemExit(f'dry-run created {name}; inspect the preview before continuing') + if snapshot() != host_before: + raise SystemExit('preview changed the private managed-file fixture; inspect the output routing before continuing') + (destination / 'results.json').write_text(json.dumps(records, indent=2) + '\n') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('before', type=lambda value: Path(value).resolve()) + parser.add_argument('after', type=lambda value: Path(value).resolve()) + parser.add_argument('destination', type=Path) + parser.add_argument('host_fixture', type=lambda value: Path(value).resolve()) + arguments = parser.parse_args() + capture(arguments.before, arguments.after, arguments.destination, arguments.host_fixture)