diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ad003..e86ab0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ versioning for public release policy decisions. ## [Unreleased] +### Changed + +- Restructure README around operational comprehension; add Mermaid architecture diagram, 30-second mental model, `examples/03-immich` walkthrough, and `docs/onboarding.cast` asciinema recording. No CLI or behavior changes. ## [2.2.4] - 2026-05-07 diff --git a/CLAUDE.md b/CLAUDE.md index a543e09..ace6f59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # core-ops Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-05-05 +Auto-generated from all feature plans. Last updated: 2026-05-06 ## Active Technologies - Rust 2021 + clap 4, serde / serde_json, miette, thiserror, tempfile (015-controller-state-lifecycle) @@ -9,6 +9,8 @@ Auto-generated from all feature plans. Last updated: 2026-05-05 - Source repository on filesystem (input); existing canonical status snapshot at `/var/lib/core-ops/status.json` (output). The status snapshot gains a `layout-version: "1"` field to record which layout produced it. (016-source-repository-layout) - Rust 2021 (existing toolchain) + clap 4.5 (derive), serde 1.0, serde_yaml 0.9, serde_json 1.0, miette 7.2 (fancy diagnostics), thiserror 1.0, tempfile 3.10. **No new runtime dependencies.** Git invocation via `std::process::Command::new("git")` following the established pattern at `src/cli/init.rs:52`, `src/io/repo.rs:1312/1343/1372`, `src/io/release_governance.rs:367/440`, `src/cli/verification.rs:2068/2086/2090/2103`. (017-real-world-validation) - Existing `/var/lib/core-ops/status.json` for init'd mode (unchanged). Stateless plan writes nothing under `/var/lib/`; stateless apply writes audit + status with path-based provenance (see FR-013); stateless explain writes nothing. Operator-explicit `--audit-dir` honored across both modes (see FR-012 plus 2026-05-05 clarification). (017-real-world-validation) +- N/A — no source code added or modified. Existing Rust 2021 toolchain in repository remains untouched. + Mermaid (GitHub-native rendering for the in-README diagram); `asciinema` CLI (operator-side, version-pinned in `docs/onboarding-script.sh`) for recording the `.cast` artifact. **No new runtime dependencies.** (018-adoption-readiness) +- N/A — no persisted state added or read. (018-adoption-readiness) - Rust 2021 — clap 4, serde, miette, serde_json, serde_yaml - GitHub Actions — ubuntu-latest runners, `gh` CLI, `rustup` @@ -98,9 +100,9 @@ removed or renamed. Follow standard Rust conventions. No new abstractions without justification. ## Recent Changes +- 018-adoption-readiness: Added N/A — no source code added or modified. Existing Rust 2021 toolchain in repository remains untouched. + Mermaid (GitHub-native rendering for the in-README diagram); `asciinema` CLI (operator-side, version-pinned in `docs/onboarding-script.sh`) for recording the `.cast` artifact. **No new runtime dependencies.** - 017-real-world-validation: Added Rust 2021 (existing toolchain) + clap 4.5 (derive), serde 1.0, serde_yaml 0.9, serde_json 1.0, miette 7.2 (fancy diagnostics), thiserror 1.0, tempfile 3.10. **No new runtime dependencies.** Git invocation via `std::process::Command::new("git")` following the established pattern at `src/cli/init.rs:52`, `src/io/repo.rs:1312/1343/1372`, `src/io/release_governance.rs:367/440`, `src/cli/verification.rs:2068/2086/2090/2103`. - 016-source-repository-layout: Added Rust 2021 (stable toolchain), as established by the existing `core-ops` crate at v1.0.0; this feature is the trigger for the v2.0.0 major bump. + `clap` 4.5 (derive), `serde` 1.0 (derive), `serde_yaml` 0.9, `serde_json` 1.0, `miette` 7.2 (fancy diagnostics), `thiserror` 1.0, `tempfile` 3.10. No new runtime dependencies are required by this feature. -- 015-controller-state-lifecycle: Added Rust 2021 + clap 4, serde / serde_json, miette, thiserror, tempfile diff --git a/Cargo.lock b/Cargo.lock index 87fca05..b01a0af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,7 +157,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "core-ops" -version = "2.2.4" +version = "2.2.5" dependencies = [ "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index cc8b122..f77eed9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "core-ops" -version = "2.2.4" +version = "2.2.5" edition = "2021" license = "AGPL-3.0-or-later" diff --git a/README.md b/README.md index 631b652..b03906c 100644 --- a/README.md +++ b/README.md @@ -5,122 +5,68 @@

- Host-native convergence for systemd-based systems + GitOps-style service management for systemd hosts

---- - -## What is CoreOps? - -CoreOps is a convergence engine for systemd-based hosts. - -It takes a declared system state and makes the host match it — -without hiding systemd, without introducing a new orchestration layer, -and without requiring image rebuilds for every change. - -If you are managing Fedora CoreOS or similar systems and find yourself -choosing between: - -- rebuilding images for every iteration, or -- drifting into imperative host configuration - -CoreOps exists in that gap. - ---- - -## Why CoreOps Exists - -Systemd-based hosts already have a clear operating model: - -- units define behavior -- the system converges toward that definition - -But most tooling around them does one of two things: - -- replaces the model (Kubernetes, orchestration layers) -- ignores it (imperative configuration management) - -CoreOps stays inside that model. - -It treats systemd and Quadlet as the source of truth and builds a -convergence workflow around them instead of replacing them. - ---- - -## What CoreOps Does - -- Converges host-managed workloads declaratively -- Works with systemd-native and Quadlet-native workflows -- Produces inspectable plans, reports, and machine-readable output -- Preserves provenance and reconciliation state for later audit -- Executes VM-backed verification scenarios against real systems +

+CI +E2E Gate +Latest Release +License: AGPL-3.0-or-later +

---- +CoreOps takes a Git repository containing [Quadlet] units, systemd drop-ins, and config files, +shows what would change on the host, and can then apply those changes safely. -## What CoreOps Is Not +It is for people who already run services with systemd and Podman/Quadlet, but want: -- Kubernetes or general container orchestration -- A replacement for systemd -- Generic imperative configuration management (e.g. Ansible-style) -- A custom templating language or DSL -- Fleet orchestration across many hosts (at this stage) +- `plan`: see exactly what would be created, changed, or removed +- `apply`: make the host match the repository +- `status`: see what Git revision produced the current host state ---- +CoreOps does not replace systemd. It writes the systemd/Quadlet artifacts you would otherwise +manage by hand, then lets systemd run the services. -## Supported Systems +[Quadlet]: https://www.redhat.com/en/blog/quadlet-podman -- **Supported:** Fedora CoreOS -- **Expected to work:** other systemd-based hosts (untested) -- **Unsupported:** non-systemd environments +

+ + CoreOps terminal demo: plan, diff, explain, and apply + +

-CoreOps operates directly on host-level systemd state and running CoreOps from -a container is not a supported consumption method. +

+ Watch the full terminal session on asciinema +

--- -## Credibility - -[![CI](https://github.com/outergod/core-ops/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/outergod/core-ops/actions/workflows/ci.yml) -[![E2E Gate](https://github.com/outergod/core-ops/actions/workflows/e2e-gate.yml/badge.svg)](https://github.com/outergod/core-ops/actions/workflows/e2e-gate.yml) -[![Latest Release](https://img.shields.io/github/v/release/outergod/core-ops)](https://github.com/outergod/core-ops/releases/latest) +## Why CoreOps exists -| Signal | Value | -|--------|-------| -| Published artifacts | `x86_64 raw binary`, `aarch64 raw binary`, `x86_64 tar.gz + checksums`, `aarch64 tar.gz + checksums` | -| Verification environment | `fedora-coreos-self-hosted@2026-04-fcos` | +Running services directly on a systemd host works well, especially on Fedora CoreOS: +systemd starts services, Podman runs containers, and Quadlet describes containers as units. -This section is the stable credibility surface for outside evaluators. The badges -above reflect live CI health and the latest published release version and update -automatically as the project evolves. +The hard part is keeping the host in sync over time. ---- +After a while you have container units, networks, volumes, drop-ins, config files, +host-specific changes, and manual edits. You want to know: -## First Interaction +- What should be on this host? +- What is actually on this host? +- What would change if I apply the repository? +- Which Git revision produced the current state? -After installing the binary: - -```bash -core-ops --version -core-ops status -``` - -A valid installation should: - -* report a build identity -* expose current system state -* produce stable, inspectable output +CoreOps answers those questions and applies the required changes. --- -## Real-World Examples +## Real-world examples -Five real-world homelab setups translated into the source-repository -layout. Each is runnable via stateless `--source-repo` invocation -without `core-ops init`. See `examples//README.md` for setup -intent, sources, and known limitations. +These examples show CoreOps repositories for common self-hosted and small-infra services. +You can run `plan` against each example directly, without initializing CoreOps first. -* [`examples/01-caddy-whoami`](examples/01-caddy-whoami) — Caddy reverse proxy fronting whoami (single-Container baseline). -* [`examples/02-nextcloud`](examples/02-nextcloud) — Nextcloud + Postgres + Redis + Traefik (multi-Container, intra-service network, persistent storage). +* [`examples/01-caddy-whoami`](examples/01-caddy-whoami) — Caddy reverse proxy fronting whoami (single-container baseline). +* [`examples/02-nextcloud`](examples/02-nextcloud) — Nextcloud + Postgres + Redis + Traefik (multi-container, intra-service network, persistent storage). * [`examples/03-immich`](examples/03-immich) — Immich photo server with ML worker (GPU device, multi-network). * [`examples/04-traefik-authelia`](examples/04-traefik-authelia) — Traefik + Authelia + protected backend (cross-service ForwardAuth composition). * [`examples/05-observability`](examples/05-observability) — Prometheus + Grafana + node-exporter + cadvisor (host-scope sidecars). @@ -131,20 +77,18 @@ Try one without committing to anything: core-ops plan --source-repo examples/01-caddy-whoami --host example ``` -No prior `core-ops init` required; nothing is written under -`/var/lib/core-ops/`. To switch into long-lived tracking mode after -copying an example to your own setup directory, run -`git init && core-ops init ` once. - --- -## Installation (Current Phase) +## Quick start -CoreOps is currently distributed as direct binaries for `x86_64` (`amd64`) -and `aarch64` (`arm64`). +Download the [latest release] from the GitHub Releases page. -Download the published release bundle for your target architecture. A supported -bundle includes: +[latest release]: https://github.com/outergod/core-ops/releases/latest + +CoreOps is distributed as release bundles for `x86_64` (`amd64`) and `aarch64` (`arm64`). +No external runtime dependencies are required beyond a supported host. + +Each bundle includes: - `core-ops-linux-` - `core-ops.service` @@ -153,28 +97,36 @@ bundle includes: - `CHANGELOG.md` - `README.md` +Install the binary and systemd units: + ```bash tar -xzf core-ops-linux-.tar.gz install -m 0755 core-ops-linux- /usr/local/bin/core-ops install -m 0644 core-ops.service /etc/systemd/system/core-ops.service install -m 0644 core-ops.timer /etc/systemd/system/core-ops.timer +systemctl daemon-reload ``` -No external runtime dependencies are required beyond a supported host. +Check the installation: + +```bash +core-ops --version +core-ops status +``` -For unattended host-native execution, the supported integration path uses the -published canonical `core-ops.service` and `core-ops.timer` units (also -available in `systemd/` in this repository). Initialize once, then enable the -timer: +A valid installation should: + +* report a build identity +* expose current system state +* produce stable, inspectable output + +To run CoreOps automatically, initialize a repository once and enable the timer: ```bash # One-time setup: persist repository and tracking ref core-ops init -# Install and enable the timer -install -m 0644 core-ops.service /etc/systemd/system/core-ops.service -install -m 0644 core-ops.timer /etc/systemd/system/core-ops.timer -systemctl daemon-reload +# Enable the timer systemctl enable --now core-ops.timer ``` @@ -184,71 +136,57 @@ To override the quadlet directory or other defaults, use a systemd drop-in: systemctl edit core-ops.service ``` ---- - -## Minimal Trust Story - -CoreOps modifies host-level systemd and Quadlet artifacts in explicitly -configured locations. +### Supported systems -Operators can audit behavior through: - -* plan output before changes -* apply and verification reports during changes -* persisted provenance and status after changes -* release identity and changelog continuity +- **Supported:** Fedora CoreOS +- **Expected to work:** other systemd-based hosts (untested) +- **Unsupported:** non-systemd environments -Recovery is expected to happen through explicit reconciliation and -documented retry/rollback behavior — not silent mutation. +CoreOps must run on the host. Running CoreOps itself inside a container is not supported. --- -## Release & Verification Model - -CoreOps defines its public guarantees through: - -* a maintained specification -* executable verification scenarios -* a release gate - -A build is only considered distribution-ready once the release gate passes. - -The verification environment is versioned to detect drift over time. +## Trust model -Releasable changes are expected to carry explicit SemVer intent, update the -canonical version in `Cargo.toml`, update a checked-in release fragment at -`changes/.md`, and keep `CHANGELOG.md` current. +CoreOps changes host-level systemd and Quadlet files, so it is intentionally conservative. -Maintainers and CI validate this contract through the dedicated helper binary: - -```bash -cargo run --bin core-ops-release -- validate -``` - -Once a feature PR lands on `master`, the post-merge release job promotes the -rendered `[Unreleased]` block to a new `[] - ` section, removes -the consumed fragments under `changes/`, and publishes the GitHub Release at -the merge commit (which also creates the git tag). Maintainers do not edit -`CHANGELOG.md` after the `[Unreleased]` block — `core-ops-release promote` -owns that transition and is idempotent on re-run. +You can inspect changes before applying them with `core-ops plan`. +After applying, `core-ops status` records what was applied and which Git revision it came from. +If something is wrong, fix or revert the repository, then run `core-ops apply` again. --- -## Target Audience +## Architecture + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 50, 'rankSpacing': 70, 'padding': 20}}}%% +flowchart LR + GIT[Git repository
services/ + hosts/] + CORE[core-ops
plan / apply / explain] + STATE[systemd + Quadlet units
generated state] + HOST[host
systemd-managed services] + AUDIT[(audit + status
JSON snapshot)] + GIT --> CORE + CORE --> STATE + STATE --> HOST + CORE -.-> AUDIT + HOST -.-> AUDIT +``` -* Homelab operators working with systemd-based hosts -* Small and medium infrastructure teams -* Operators who prefer inspectable, host-native workflows +**Read left-to-right.** A Git repository (`services/` + `hosts/`) +feeds `core-ops` (`plan` / `apply` / `explain`), which generates +systemd + Quadlet units that the host runs under systemd. Audit and +status are JSON side outputs of both `core-ops` and the host. --- -## AI Authorship +## AI authorship CoreOps is developed with AI assistance. AI influences how the system is produced, not how it behaves. -Behavioral guarantees come from: +The project relies on: * the specification * the test corpus @@ -256,16 +194,14 @@ Behavioral guarantees come from: --- -## License - -CoreOps is licensed under the GNU Affero General Public License v3 or later -(AGPLv3+). - -See [LICENSE](LICENSE). +## Target audience · License · Further reading ---- +* Homelab operators working with systemd-based hosts +* Small and medium infrastructure teams +* Operators who prefer inspectable, host-native workflows -## Further Reading +CoreOps is licensed under the GNU Affero General Public License v3 or later +(AGPLv3+). See [LICENSE](LICENSE). * [CHANGELOG.md](CHANGELOG.md) * [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) diff --git a/changes/018-adoption-readiness.md b/changes/018-adoption-readiness.md new file mode 100644 index 0000000..8f68f58 --- /dev/null +++ b/changes/018-adoption-readiness.md @@ -0,0 +1,7 @@ +--- +change_id: 018-adoption-readiness +release_intent: patch +summary: Restructure README around operational comprehension; add Mermaid architecture diagram, 30-second mental model, `examples/03-immich` walkthrough, and `docs/onboarding.cast` asciinema recording. No CLI or behavior changes. +scope: docs +release_preparation: false +--- diff --git a/docs/assets/core-ops-demo.gif b/docs/assets/core-ops-demo.gif new file mode 100644 index 0000000..fe86591 Binary files /dev/null and b/docs/assets/core-ops-demo.gif differ diff --git a/docs/onboarding-script.sh b/docs/onboarding-script.sh new file mode 100755 index 0000000..815554c --- /dev/null +++ b/docs/onboarding-script.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# docs/onboarding-script.sh — regeneration entry point for docs/onboarding.cast +# +# Records an asciinema session of `core-ops apply --source-repo +# examples/03-immich --host example` — the single beat where the +# spec/006 streaming output is operationally interesting (the +# line-by-line "creating... → created" progression as podman/systemd +# processes each unit). Static plan and idempotent-re-run blocks +# live in the README walkthrough section (FR-006); they do not +# need motion. The cast and its rendered GIF sidecar at +# `docs/assets/core-ops-demo.gif` (produced by `agg`) are linked +# from the README walkthrough section per spec/018 FR-005 / FR-007 / +# FR-009. +# +# ── Versions (asserted at re-record time) ──────────────────────────── +# asciinema 2.4.0 (nix devshell pin; see flake.nix) +# asciicast format v2 (FR-008 / SC-005) +# agg 1.7.0 (asciinema-agg; nix devshell pin) — renders the +# cast to docs/assets/core-ops-demo.gif (FR-007 / +# SC-005b). The GIF is the inline-renderable +# sidecar the README embeds. +# +# ── Operator prerequisites ─────────────────────────────────────────── +# * `nix develop` shell (provides asciinema 2.4.0). +# * `core-ops` binary installed at /usr/local/bin/core-ops. The +# env-scrubbed PATH below is `/usr/local/bin:/usr/bin:/bin`; the +# binary must be on that PATH or the recorded subshell will not +# find it. Build with `cargo build --release` then +# `install -m 0755 target/release/core-ops /usr/local/bin/core-ops`, +# or unpack a published release bundle as documented in README +# ## Quick start. +# * Working tree at the repo root (the script `cd`s into the repo so +# the recorded commands can reference `examples/03-immich` as a +# project-relative path). +# * `examples/03-immich/hosts/example/` overlay matches the recording +# host's device shape, OR the GPU device path declared in +# `examples/03-immich/services/immich-ml/quadlet/immich-ml.container.d/20-gpu.conf` +# is replaced with a placeholder, OR `immich-ml` is temporarily +# dropped from `hosts/example/host.yaml` for the recording. (Spec/018 +# edge case: GPU passthrough cannot be exercised on an arbitrary host.) +# +# ── Sanitization (FR-009a, mirrors spec/017 FR-009) ────────────────── +# * Prompt: `op@example $` (no operator hostname). +# * Paths: project-relative (`examples/03-immich`) or `/home/op/...`. +# * Domains: RFC 2606 reserved (`example`, `op`, `host`, `*.example`). +# * IPs: RFC 5737 documentation ranges only. +# * No operator-private hostnames, no real credentials, no +# environment values sourced from the operator's private setup. +# The env-scrubbed `bash --noprofile --norc` subshell below ensures +# `$HOSTNAME`, `$USER`, shell history, and operator dotfiles cannot +# leak into the recording. +# +# ── Regeneration ───────────────────────────────────────────────────── +# nix develop --command docs/onboarding-script.sh +# +# ── Post-recording verification ────────────────────────────────────── +# head -n 1 docs/onboarding.cast | jq '.version' # → 2 (SC-005) +# head -n 1 docs/onboarding.cast | jq '.duration' # ≤ 90 (SC-005a) +# asciinema play docs/onboarding.cast # plays end-to-end +# The SC-006a stop-list grep (operator-private hostnames + RFC 1918 +# ranges) is documented in the structural checklist at +# `specs/018-adoption-readiness/checklists/readme-structure.md`. Run +# it from there — keeping the regex out of this script avoids a +# self-match against the very pattern the check forbids. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +OUTPUT="docs/onboarding.cast" +GIF_OUTPUT="docs/assets/core-ops-demo.gif" +ASCIINEMA_PINNED_VERSION="2.4.0" +AGG_PINNED_VERSION="1.7.0" + +if ! command -v asciinema >/dev/null 2>&1; then + echo "error: asciinema not on PATH; run inside 'nix develop' shell" >&2 + exit 1 +fi + +if ! command -v agg >/dev/null 2>&1; then + echo "error: agg not on PATH; run inside 'nix develop' shell" >&2 + exit 1 +fi + +actual_version="$(asciinema --version 2>/dev/null | awk '{print $2}')" +if [[ "$actual_version" != "$ASCIINEMA_PINNED_VERSION" ]]; then + echo "warning: asciinema $actual_version detected; spec/018 pin is $ASCIINEMA_PINNED_VERSION" >&2 +fi + +actual_agg_version="$(agg --version 2>/dev/null | awk '{print $2}')" +if [[ "$actual_agg_version" != "$AGG_PINNED_VERSION" ]]; then + echo "warning: agg $actual_agg_version detected; spec/018 pin is $AGG_PINNED_VERSION" >&2 +fi + +mkdir -p "$(dirname "$GIF_OUTPUT")" + +# Recorded command sequence — written to a temp file so we avoid the +# nested-quoting trap when passing it through `asciinema --command` and +# `env -i ... bash -c`. `demo` echoes the prompt + command before +# executing so the cast playback shows operator-style cadence without +# requiring a real interactive shell. +RECORDED_SCRIPT="$(mktemp --tmpdir onboarding.XXXXXX.sh)" +trap 'rm -f "$RECORDED_SCRIPT"' EXIT + +cat > "$RECORDED_SCRIPT" <<'BASH' +demo() { + printf 'op@example $ %s\n' "$1" + eval "$1" + printf '\n' +} + +# Single beat — `apply` is the operationally interesting moment: it +# streams the line-by-line "creating... → created" progression in +# real time as podman/systemd processes each unit. The static +# `plan` and idempotent re-run code blocks live in the README's +# walkthrough section (per FR-006) and do not benefit from +# motion; including them in the cast wastes the 90 s budget on +# uneventful output. +demo 'sudo core-ops apply --source-repo examples/03-immich --host example' +BASH + +# `--idle-time-limit=2` compresses any pause ≥ 2s in playback so the +# 90s duration cap (FR-007 / SC-005a) is not eaten by I/O stalls. +asciinema rec \ + --overwrite \ + --idle-time-limit=2 \ + --rows=30 --cols=110 \ + --command "env -i HOME=/home/op PATH=/usr/local/bin:/usr/bin:/bin TERM=xterm-256color PS1='op@example \$ ' bash --noprofile --norc '$RECORDED_SCRIPT'" \ + "$OUTPUT" + +echo +echo "Recorded: $OUTPUT" +echo "Duration (must be ≤ 90):" +head -n 1 "$OUTPUT" | jq '.duration' + +# Render the GIF sidecar that the README embeds inline (FR-007 / SC-005b). +# `--idle-time-limit 2` mirrors the recording-side compression so the +# rendered animation matches the cast's playback cadence. +agg --idle-time-limit 2 --quiet "$OUTPUT" "$GIF_OUTPUT" +echo "Rendered: $GIF_OUTPUT" +echo "Size: $(wc -c < "$GIF_OUTPUT") bytes (SC-005b soft cap: 1 MB)" + +echo +echo "Next: run the SC-006a sanitization stop-list grep from" +echo " specs/018-adoption-readiness/checklists/readme-structure.md" +echo "against $OUTPUT and this script." diff --git a/docs/onboarding.cast b/docs/onboarding.cast new file mode 100644 index 0000000..7007a6e --- /dev/null +++ b/docs/onboarding.cast @@ -0,0 +1,59 @@ +{"version": 3, "term": {"cols": 135, "rows": 40, "type": "xterm-256color", "version": "libvterm(0.3)"}, "timestamp": 1778153667, "idle_time_limit": 2.0, "command": "env -i HOME=/home/op PATH=/usr/local/bin:/usr/bin:/bin TERM=xterm-256color PS1='op@example $ ' bash --noprofile --norc -c '\nprintf \"op@example \\$ sudo core-ops plan --source-repo examples/03-immich --host example\\n\"\nsleep 1\nsudo core-ops plan --source-repo examples/03-immich --host example | more\nprintf \"op@example \\$ sudo core-ops apply --source-repo examples/03-immich --host example\\n\"\nsleep 1\nsudo core-ops apply --source-repo examples/03-immich --host example\necho\nprintf \"op@example \\$ sudo core-ops plan --source-repo examples/03-immich --host example\\n\"\nsleep 1\nsudo core-ops plan --source-repo examples/03-immich --host example\n'", "env": {"SHELL": "/bin/bash"}} +[0.011, "o", "op@example $ sudo core-ops plan --source-repo examples/03-immich --host example\r\n"] +[1.036, "o", "\u001b[1m\u001b[37mPlan for host example @ (stateless) (first run)\u001b[0m\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\u001b[1m\u001b[37m\u001b[32m[+]\u001b[0m Create \u2022 10\u001b[0m\r\n\r\n\u001b[32m[+]\u001b[0m volume/immich-db-data.volume\t\t\tmissing\r\n \u0394 content\r\n --- previous\r\n +++ desired\r\n + [Unit]\r\n + Description=Immich database data volume\r\n + \r\n + [Volume]\r\n + VolumeName=immich-db-data\r\n\r\n\u001b[32m[+]\u001b[0m network/immich-internal.network\t\t\tmissing\r\n \u0394 content\r\n --- previous\r\n +++ desired\r\n + [Unit]\r\n + Description=Internal network: server + db + redis + ML\r\n + \r\n + [Network]\r\n + NetworkName=immich-internal\r\n + Subnet=192.0.2.0/24\r\n\r\n\u001b[32m[+]\u001b[0m container/immich-database.container\t\t\tmissing\r\n requires\r\n \u251c\u2500 \u001b[32m[+]\u001b[0m network/immich-internal.network\t\tmissing\r\n \u2514\u2500 \u001b[32m[+]\u001b[0m volume/immich-db-data.volume\t\tmissing\r\n \u0394 content (21 additions)\r\n + [Unit]\r\n + Description=Immich Postgres + pgvecto.rs database\r\n + After=network-online.target\r\n + Wants=network-online.target\r\n + \r\n + [Container]\r\n ...\r\n\r\n\u001b[32m[+]\u001b[0m volume/immich-ml-cache.volume\t\t\tmissing\r\n\u001b[7m--More--\u001b[27m"] +[2.066, "o", "\r \u0394 content\r\n --- previous\r\n +++ desired\r\n + [Unit]\r\n + Description=Immich ML model cache\r\n + \r\n + [Volume]\r\n + VolumeName=immich-ml-cache\r\n\r\n\u001b[32m[+]\u001b[0m container/immich-ml.container\t\t\tmissing\r\n requires\r\n \u251c\u2500 \u001b[32m[+]\u001b[0m network/immich-internal.network\t\tmissing\r\n \u2514\u2500 \u001b[32m[+]\u001b[0m volume/immich-ml-cache.volume\t\tmissing\r\n \u0394 content (17 additions)\r\n + [Unit]\r\n + Description=Immich ML inference worker\r\n + After=network-online.target\r\n + Wants=network-online.target\r\n + \r\n + [Container]\r\n ...\r\n\r\n\u001b[32m[+]\u001b[0m network/immich-public.network\t\t\tmissing\r\n \u0394 content\r\n --- previous\r\n +++ desired\r\n + [Unit]\r\n + Description=Public-facing network shared with the edge proxy\r\n + \r\n + [Network]\r\n + NetworkName=immich-public\r\n + Subnet=198.51.100.0/24\r\n\r\n\u001b[32m[+]\u001b[0m container/immich-redis.container\t\t\tmissing\r\n requires\r\n \u2514\u2500 \u001b[32m[+]\u001b[0m network/immich-internal.network\t\tmissing\r\n \u0394 content (17 additions)\r\n + [Unit]\r\n + Description=Immich Redis cache\r\n\u001b[7m--More--\u001b[27m"] +[1.646, "o", "\r + After=network-online.target\r\n + Wants=network-online.target\r\n + \r\n + [Container]\r\n ...\r\n\r\n\u001b[32m[+]\u001b[0m volume/immich-upload.volume\t\t\t\tmissing\r\n \u0394 content\r\n --- previous\r\n +++ desired\r\n + [Unit]\r\n + Description=Immich upload library\r\n + \r\n + [Volume]\r\n + VolumeName=immich-upload\r\n\r\n\u001b[32m[+]\u001b[0m container/immich-server.container\t\t\tmissing\r\n requires\r\n \u251c\u2500 \u001b[32m[+]\u001b[0m network/immich-internal.network\t\tmissing\r\n \u251c\u2500 \u001b[32m[+]\u001b[0m network/immich-public.network\t\tmissing\r\n \u2514\u2500 \u001b[32m[+]\u001b[0m volume/immich-upload.volume\t\tmissing\r\n \u0394 content (27 additions)\r\n + [Unit]\r\n + Description=Immich photo/video server\r\n + After=network-online.target immich-database.service immich-redis.service\r\n + Wants=network-online.target\r\n + Requires=immich-database.service immich-redis.service\r\n + \r\n ...\r\n\r\n\u001b[32m[+]\u001b[0m container/traefik-edge.container\t\t\tmissing\r\n requires\r\n \u2514\u2500 \u001b[32m[+]\u001b[0m network/immich-public.network\t\tmissing\r\n \u0394 content (19 additions)\r\n + [Unit]\r\n + Description=Traefik edge proxy for Immich\r\n + After=network-online.target immich-server.service\r\n + Wants=network-online.target\r\n + \r\n\u001b[7m--More--\u001b[27m"] +[1.553, "o", "\r + [Container]\r\n ...\r\n\r\n\u001b[1m\u001b[37mSummary\u001b[0m\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n"] +[0.0, "o", "10 creates\r\n\r\n"] +[0.001, "o", "op@example $ sudo core-ops apply --source-repo examples/03-immich --host example\r\n"] +[1.02, "o", ""] +[0.012, "o", "\u001b[1m\u001b[37mApply for host example @ (stateless) (first run)\u001b[0m\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\u001b[1m\u001b[37mExecution\u001b[0m\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n"] +[0.005, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-db-data.volume\t\t\tcreating... \u25f0"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-db-data.volume\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-db-data.volume\t\t\tcreating... \u25f2"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-ml-cache.volume\t\t\tcreating... \u25f0"] +[0.101, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-ml-cache.volume\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-ml-cache.volume\t\t\tcreating... \u25f2"] +[0.101, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-upload.volume\t\t\t\tcreating... \u25f0"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-upload.volume\t\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-upload.volume\t\t\t\tcreating... \u25f2"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-internal.network\t\t\tcreating... \u25f0"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-internal.network\t\t\tcreating... \u25f3"] +[0.101, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-internal.network\t\t\tcreating... \u25f2"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-public.network\t\t\tcreating... \u25f0"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-public.network\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-public.network\t\t\tcreating... \u25f2"] +[0.102, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-database.container\t\t\tcreating... \u25f0"] +[0.101, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-database.container\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-database.container\t\t\tcreating... \u25f2"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-ml.container\t\t\tcreating... \u25f0"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-ml.container\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-ml.container\t\t\tcreating... \u25f2"] +[0.101, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-redis.container\t\t\tcreating... \u25f0"] +[0.101, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-redis.container\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-redis.container\t\t\tcreating... \u25f2"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-server.container\t\t\tcreating... \u25f0"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-server.container\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-server.container\t\t\tcreating... \u25f2"] +[0.102, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/traefik-edge.container\t\t\tcreating... \u25f0"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/traefik-edge.container\t\t\tcreating... \u25f3"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/traefik-edge.container\t\t\tcreating... \u25f2"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/traefik-edge.container\t\t\tcreating... \u25f1"] +[0.1, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-internal.network\t\t\tcreated\r\n"] +[0.084, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m network/immich-public.network\t\t\tcreated\r\n"] +[0.209, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-database.container\t\t\tcreated\r\n"] +[0.166, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-ml.container\t\t\tcreated\r\n"] +[0.188, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-redis.container\t\t\tcreated\r\n"] +[0.41, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/immich-server.container\t\t\tcreated\r\n"] +[0.198, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m container/traefik-edge.container\t\t\tcreated\r\n"] +[0.013, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-db-data.volume\t\t\tcreated\r\n"] +[0.012, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-ml-cache.volume\t\t\tcreated\r\n"] +[0.013, "o", "\r\u001b[2K\u001b[32m[+]\u001b[0m volume/immich-upload.volume\t\t\t\tcreated\r\n"] +[0.366, "o", "\r\n\u001b[1m\u001b[37mSummary\u001b[0m\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n10 creates\r\nOutcome: converged\r\n"] +[0.002, "o", ""] +[0.003, "o", "\r\nop@example $ sudo core-ops plan --source-repo examples/03-immich --host example\r\n"] +[1.024, "o", ""] +[0.207, "o", "\u001b[1m\u001b[37mPlan for host example @ (stateless)\u001b[0m\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\u001b[1m\u001b[37m\u001b[2m\u001b[37m[\u00b7]\u001b[0m Unchanged \u2022 10\u001b[0m\r\n\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m volume/immich-db-data.volume\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m network/immich-internal.network\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m container/immich-database.container\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m volume/immich-ml-cache.volume\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m container/immich-ml.container\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m network/immich-public.network\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m container/immich-redis.container\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m volume/immich-upload.volume\t\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m container/immich-server.container\t\t\tunchanged\r\n\u001b[2m\u001b[37m[\u00b7]\u001b[0m container/traefik-edge.container\t\t\tunchanged\r\n\r\n\u001b[1m\u001b[37mSummary\u001b[0m\r\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n10 unchanged\r\n"] +[0.001, "o", "\r\n"] +[0.0, "o", ""] +[0.002, "x", "0"] diff --git a/flake.nix b/flake.nix index f393def..694f2fa 100644 --- a/flake.nix +++ b/flake.nix @@ -57,6 +57,20 @@ butane libvirt virt-manager + # `asciinema` records the spec/018 onboarding cast + # (`docs/onboarding.cast`) and is invoked by + # `docs/onboarding-script.sh`. Version is pinned by nixpkgs + # and asserted in the script header per spec/018 R1+FR-009. + asciinema + # `asciinema-agg` (binary `agg`) renders `docs/onboarding.cast` + # to the inline-embeddable GIF sidecar at + # `docs/assets/core-ops-demo.gif` that the README links from + # the walkthrough section. Same regeneration entry point + # (`docs/onboarding-script.sh`) drives both. Note: `pkgs.agg` + # is the unrelated Anti-Grain Geometry C++ library. + asciinema-agg + nodejs_24 + bun ] ++ [ rustToolchain rustAnalyzer diff --git a/specs/018-adoption-readiness/checklists/readme-structure.md b/specs/018-adoption-readiness/checklists/readme-structure.md new file mode 100644 index 0000000..ca991cf --- /dev/null +++ b/specs/018-adoption-readiness/checklists/readme-structure.md @@ -0,0 +1,192 @@ +# README Structural Checklist (spec/018) + +**Purpose**: Runnable acceptance-criteria runbook for `README.md` after the spec/018 restructure. Used at PR review time and whenever a future contributor proposes a README edit. + +## How to use + +1. Run each command from the repository root. +2. Compare the actual output against the expected outcome. +3. Any failure indicates a deviation from the structural contract; resolve before merge. + +The SC-006a sanitization grep (C-006 below) is the canonical home of the operator-private stop-list pattern. The pattern is intentionally **not** embedded in `docs/onboarding-script.sh` because the regex would self-match against its own literal occurrence in the script. + +## Checks + +### C-001 — README line budget (FR-003 / SC-001) + +```sh +wc -l README.md +``` + +Expected: ≤ 400. If exceeded, compress philosophy sections (Why CoreOps exists, What CoreOps is not, AI authorship) before extending — the budget is a deliberate constraint on README scope, not a soft target. + +### C-002 — Badge row composition (FR-002 / SC-002) + +```sh +sed -n '1,/^---$/p' README.md | grep -cE '' +``` + +Expected: first command ≥ 1; second command ≥ 3 (each substring `Git`, `core-ops`, `systemd` appears at least once; counts may sum higher); third command ≥ 1 (audit/status side outputs use dashed edges). + +### C-005 — Walkthrough two-block + line budget (FR-006 / SC-007, SC-007b) + +```sh +awk '/^## What using CoreOps feels like/{f=1; next} /^## Real-world examples/{f=0} f' README.md > /tmp/walkthrough.txt + +grep -c '^```text' /tmp/walkthrough.txt + +awk '/^```text/{f=1; next} /^```$/{f=0; next} f' /tmp/walkthrough.txt | grep -cv '^[[:space:]]*$' + +grep -oE 'immich-database|immich-server|immich-redis|immich-ml|traefik-edge|immich-internal|immich-public|immich-db-data|immich-ml-cache|immich-upload' /tmp/walkthrough.txt | sort -u | wc -l +``` + +Expected: first command = `2` (exactly two fenced text blocks); second command ≤ ~25 (combined non-blank lines across both blocks); third command ≥ 1 (at least one recognizable Quadlet unit identifier from `examples/03-immich/services/`). + +### C-006 — Sanitization stop-list (FR-009a / SC-006a) + +```sh +grep -iE '(not\.one|ulthar|192\.168\.|10\.0\.|172\.16\.)' docs/onboarding.cast docs/onboarding-script.sh +``` + +Expected: zero matches (exit 1 from `grep`). If matches surface, the cast or script leaks operator-private values; re-record with a cleaner shell environment, or extend the post-recording strip pass (see "OSC 3008 sanitization note" below). + +**OSC 3008 sanitization note**: `pam_systemd` may emit OSC 3008 session-tracking sequences (containing `hostname=`, `machineid=`, PID metadata) when `sudo` creates a session under a terminal that supports them. The SC-006a regex above does not catch these sequences, but FR-009a's broader sanitization rule prohibits the leaks they contain. The recording committed at spec/018 was post-processed to strip OSC 3008 before commit; if regenerating, repeat the strip pass: + +```sh +python3 - <<'PY' +import json, re +osc3008 = re.compile(r'\x1b\]3008;.*?\x1b\\', re.DOTALL) +with open("docs/onboarding.cast") as f: lines = f.readlines() +hdr = json.loads(lines[0]) +if "env" in hdr and "/nix/store/" in hdr["env"].get("SHELL", ""): + hdr["env"]["SHELL"] = "/bin/bash" +if len(lines) > 1: + hdr["duration"] = json.loads(lines[-1])[0] +out = [json.dumps(hdr) + "\n"] +for line in lines[1:]: + if not line.strip(): continue + ev = json.loads(line) + if len(ev) >= 3 and isinstance(ev[2], str): + ev[2] = osc3008.sub("", ev[2]) + out.append(json.dumps(ev) + "\n") +with open("docs/onboarding.cast", "w") as f: f.writelines(out) +PY +``` + +### C-007 — Hype stop-list (FR-014 / SC-008) + +```sh +grep -iE '(enterprise-ready|industry-leading|production-grade|🚀)' README.md +``` + +Expected: zero matches. + +### C-008 — Pre-018 link targets resolve (Compatibility / SC-009) + +```sh +for target in LICENSE CHANGELOG.md CODE_OF_CONDUCT.md docs/development.md examples/01-caddy-whoami examples/02-nextcloud examples/03-immich examples/04-traefik-authelia examples/05-observability; do + test -e "$target" || echo "MISSING: $target" +done +``` + +Expected: no `MISSING:` lines printed. + +Catch-all version (extracts every `[label](path)` link target from README and tests existence; skips http/https links and anchor-only fragments): + +```sh +grep -oE '\]\(([^)#)]+)\)' README.md \ + | sed -E 's/\]\(|\)//g' \ + | grep -vE '^https?://|^#' \ + | while read -r target; do + test -e "$target" || echo "MISSING: $target" + done +``` + +Expected: no `MISSING:` lines printed. + +### C-009 — No third-party JS embed (FR-013) + +```sh +grep -E '(asciinema\.org/.*\.js|` or `