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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions docs/specs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Msg>` to stderr only when `Msg` is non-empty (drift exit 2 is silent). Any other error is printed as `Error: <err>` 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.

Expand Down Expand Up @@ -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`.
7 changes: 2 additions & 5 deletions docs/specs/cli-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`) 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.
Expand Down Expand Up @@ -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 `### <path>` 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.
6 changes: 2 additions & 4 deletions docs/specs/cloudflared.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
4 changes: 0 additions & 4 deletions docs/specs/docker-nvidia.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion docs/specs/doctor-schedule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
5 changes: 3 additions & 2 deletions docs/specs/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<home_base>/.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.
Expand All @@ -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.
3 changes: 2 additions & 1 deletion docs/specs/fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
3 changes: 2 additions & 1 deletion docs/specs/gpu-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
4 changes: 0 additions & 4 deletions docs/specs/host-base.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading