Prechádzať zdrojové kódy

spec: HA add-on + recipe-repo architecture (implementation plan)

Frozen design (operator-confirmed): the sm121 launcher runs as a HA Docker
add-on (24/7, Mac-independent); recipes+config live in git repos (public/
private), cloned/pulled by the launcher; an explicit ordered repo list sets
priority (first wins); 'sm121 promote' moves a recipe across repos (git mv
+ commit + push, --dry-run/--apply); archive state in a separate
repos.json registry; hybrid docker images (repo build contexts + optional
registry pull). Consequences + mitigations, the testing strategy (file://
hermetic git tests), and the 7-step implementation order are spelled out.
Supersedes the sm121-ha-integration custom-integration prototype.
Alexander Korolev 1 týždeň pred
rodič
commit
cfbe115acb

+ 210 - 0
specs/tech-architecture/ha-addon-recipe-repos.md

@@ -0,0 +1,210 @@
+# sm121 HA Add-on + Recipe-Repo Architecture — Implementation Plan
+
+**Status:** design frozen 2026-10-10, implementation pending.
+**Supersedes:** the `sm121-ha-integration` custom-integration prototype
+(commit `1ccd032` in `sm121-ha-integration/` — keep as reference, the
+direction changed to an add-on).
+
+## Goal
+
+Run the `sm121` launcher **inside Home Assistant as a Docker add-on**
+(24/7, independent of the Mac), with **launcher/recipe separation**:
+recipes + config live in **git repos** (public or private), the launcher
+clones/pulls them, and recipes are **promoted** across repos and repos are
+**archived**. The HA UI drives serve/stop/power via the `sm121 web`
+dashboard (port 3100) exposed by the add-on.
+
+## Frozen design decisions (operator-confirmed)
+
+| Decision | Value |
+|----------|-------|
+| Form factor | HA **add-on** (Docker container running the `sm121` binary), not a custom integration |
+| SSH keys | part of the add-on config (mounted into the container) |
+| Recipes + config | from **git repos** (public or private), launcher clones/pulls |
+| Repo priority | **explicit ordered list** in config — first entry wins on a recipe-name collision |
+| Promotion | **launcher does it**: `sm121 promote <recipe> --from <repo> --to <repo>` (git mv + commit + push, `--dry-run`/`--apply`) |
+| Archive state | **separate JSON registry** (`~/.config/sm121/repos.json`), not a config flag |
+| Docker images | **hybrid**: `docker-images/` build contexts in the recipe repo (source of truth) + optional registry pull (`--image-source registry`) to skip the build |
+| HA UI | the `sm121 web` dashboard (port 3100); entities (integration) are a later phase, not blocking |
+
+## Components
+
+### 1. Launcher changes (Rust, `sm121-launcher`)
+
+New config block (ordered list; first = highest priority):
+```toml
+[[recipe_repos]]
+url = "https://git.kilork.org/sm121/recipes-production.git"
+token = "…"            # optional; private-repo credential (deploy key / PAT)
+
+[[recipe_repos]]
+url = "https://git.kilork.org/sm121/recipes-playground.git"
+token = "…"
+```
+
+New module `src/recipe_repos.rs`:
+- `sync_recipe_repos(config)` — for each non-archived `[[recipe_repos]]`,
+  clone (first time) / pull (subsequent) into a cache dir
+  (`~/.cache/sm121/recipe-repos/<sha1(url)>`), in list order. A
+  unreachable repo **warns and is skipped** (the launcher still starts with
+  the reachable ones — a dead playground repo must not take down
+  production serving).
+- Returns the list of cloned repo roots → appended to `recipe_sources`
+  (after the cwd `recipes/`), in the same order → a recipe name present in
+  an earlier (higher-priority) repo shadows the later one (the existing
+  `recipe_sources` search-order semantics already do this: earlier source
+  wins).
+- Reads the archive registry (`repos.json`); archived URLs are skipped.
+
+New CLI subcommands:
+- `sm121 promote <recipe> --from <repo-url> --to <repo-url> [--dry-run|--apply]`
+  — `git mv recipes/<recipe>/` from the source repo checkout to the target
+  repo checkout, commit, push. `--dry-run` prints the exact git commands;
+  `--apply` runs them. Refuses if the recipe exists in the target already
+  (no silent overwrite).
+- `sm121 archive <repo-url>` / `sm121 unarchive <repo-url>` — flip the
+  entry in `repos.json` (creates the file if missing). The next
+  `sync_recipe_repos` skips archived repos.
+- `sm121 repos` — list the configured repos with their state
+  (cloned? archived? last-pull ts? recipe count?).
+
+Docker-image hybrid:
+- `serve`/`build` gain `--image-source {repo,registry}` (default `repo` =
+  current on-demand build from `docker-images/`). `registry` pulls the
+  prebuilt `docker_image` ref from the `[registry]` base and skips the
+  build (the image must already be pushed; the checksum label is verified).
+
+Wiring:
+- `serve`/`stop`/`report`/`validate` call `sync_recipe_repos` first (so the
+  recipe set is current). The sync is best-effort (a failed repo degrades
+  to a warning, never blocks a serve of a recipe from a reachable repo).
+- The `sm121 web` dashboard's recipe panel reads the merged set (it already
+  does, via `recipe_sources`).
+
+### 2. Docker image (the add-on container)
+
+`docker-images/sm121-addon/` (in the launcher repo, or a build dir):
+- Base: `debian:bookworm-slim` (or `gcr.io/distroless/static` for a smaller
+  final image; debian for git + debuggability).
+- Installs `git` + copies the `sm121` binary (the release build).
+- Entrypoint (`entrypoint.sh`):
+  1. Write the effective `config.toml` from the add-on options
+     (the `recipe_repos`, the `ssh_key_path`, the `[home_assistant]`, the
+     `[registry]`).
+  2. Drop the SSH key at `ssh_key_path` (from a mounted secret).
+  3. `sm121 repos sync` (the `sync_recipe_repos`).
+  4. exec `sm121 web --port 3100 --bind 0.0.0.1` (expose the dashboard).
+  5. A supervisor loop re-syncs the repos every N minutes (a cron or a
+     background loop) so new recipes land without a container restart.
+
+The image is built from the launcher repo (a `Makefile` target or a
+`build.sh`), tagged + pushed to the `[registry]` base (the existing
+registry-publishing machinery).
+
+### 3. HA add-on (`sm121-ha-integration/` repo, restructured)
+
+- `config.yaml` — the add-on manifest: name, description, the port
+  (3100, host access), the options schema:
+  - `recipe_repos` (a YAML list of `{url, token}`),
+  - `ssh_key` (a secret — the private key to the cluster nodes),
+  - `ha_url` / `ha_token` (for the power entities the launcher already
+    uses),
+  - `registry_base` (for the `--image-source registry` pulls),
+  - `repo_resync_minutes` (the background re-sync interval).
+- `Dockerfile` — builds the add-on image (or references the prebuilt
+  registry tag).
+- The add-on mounts the options → the entrypoint writes `config.toml` +
+  the SSH key → runs the launcher.
+- HA reachability: the add-on container must route to the cluster node
+  SSH IPs (10G mgmt) + the HA REST API + the Mikrotik devices (8728). The
+  operator ensures the HA host is on a network that reaches them (the same
+  constraint the Mac has today).
+
+### 4. Recipe repos (the git repos)
+
+Two starting repos (the operator creates them on `git.kilork.org`):
+- `sm121/recipes-production` — the qualified, serving recipes
+  (higher priority, listed first).
+- `sm121/recipes-playground` — the experimental recipes (lower priority).
+
+Each repo layout (a subset of the launcher repo):
+```
+config.toml            # clusters, ssh_user, [home_assistant], [registry]
+recipes/<name>/recipe.toml
+docker-images/<dir>/   # build contexts (source of truth; optional if
+                       # the image is prebuilt in the registry)
+```
+The `config.toml` in the repo is the **cluster/config source of truth**
+(the add-on's effective config = the repo's `config.toml` + the add-on
+options for the secrets). A repo without a `config.toml` inherits the
+add-on's base config.
+
+## Consequences + mitigations (the designed-in answers)
+
+| Consequence | Mitigation in the design |
+|-------------|--------------------------|
+| A recipe repo is unreachable at startup | `sync_recipe_repos` is best-effort: unreachable repo → warn + skip; the launcher still serves from the reachable repos. A production repo down is logged loudly (the `repos` subcommand shows the state). |
+| A private repo's credential leaks / rotates | The token is an add-on option (a HA secret), not baked into the image; rotation = edit the option, no rebuild. The clone URL embeds the token only in the in-container cache, never committed. |
+| `promote` pushes to a repo the key can't write | `--dry-run` first (prints the exact git commands); `--apply` fails loud on a rejected push; the recipe stays in the source repo (no partial state — the mv is a single commit). |
+| `promote` name collision in the target | Refused up front (the recipe must not exist in the target); no silent overwrite. |
+| `repos.json` corrupt / missing | Missing → created fresh (no repos archived). Corrupt → backed up to `repos.json.bak` + a fresh one created + a warning (the launcher never crashes on the registry). |
+| Hybrid image: registry pull of a missing tag | `--image-source registry` verifies the checksum label after pull; a missing tag → clear error naming the tag + a hint to build/push first (falls back to `--image-source repo`). |
+| Add-on container crashes / the launcher OOMs | The HA add-on `config.yaml` sets the Docker restart policy (`always`); the entrypoint supervisor re-syncs repos on restart. A crash loses no state (the ledger + the repos are on disk / in git). |
+| Two launchers (Mac + HA add-on) serving the same cluster | The run-ledger (`.lock`) is per-recipe in the recipe repo (git) — a second launcher that `serve`s the same recipe sees the open run and refuses (the existing ledger semantics). The operator runs **one** launcher as the source of truth (the add-on); the Mac launcher is for dev. Documented in the add-on README. |
+| A stale cloned repo shadows a newer recipe | The background re-sync (every N minutes) + the `repos` subcommand show the last-pull ts; a `sm121 repos sync --force` re-pulls. |
+
+## Testing strategy
+
+1. **Unit (Rust, `cargo test`):**
+   - `recipe_repos.rs`: the merge order (first repo shadows later on a name
+     collision), the archive-registry skip, the best-effort skip of an
+     unreachable repo (mock the git via a trait, or a temp-dir "repo" with
+     a `file://` URL — git supports `file://` clones, no network).
+   - `repos.json`: missing → fresh, corrupt → backup + fresh, the
+     archive/unarchive round-trip.
+   - `promote`: `--dry-run` prints the right git commands (assert on the
+     printed string); `--apply` on two temp `file://` repos (git mv +
+     commit visible in the target's log); the collision refusal.
+   - Use `file://` git URLs for all repo tests (no network, hermetic).
+2. **Integration (the `validate` gate):** the `sm121 validate` command runs
+   over the merged recipe set (the existing recipe-contract gate) — a
+   promoted recipe must still pass the contract in the target repo.
+3. **Docker image:** a `make addon-image` target builds it; a smoke test
+   runs the entrypoint with a `file://` recipe repo + a stub config and
+   asserts the `sm121 web` dashboard answers on 3100 (a `curl /api/state`).
+4. **HA add-on:** manual (the operator installs it in HA): the config
+   flow, the entities, a serve/stop/power round-trip against a test
+   cluster. No automated HA test (HA is the operator's env).
+5. **End-to-end playground flow (manual, the operator):** new recipe in
+   playground → add-on re-syncs → serve → bench → `sm121 promote` →
+   production → the add-on serves it from production → `sm121 archive`
+   the playground repo → the recipe is still served (from production).
+
+## Implementation order (the sequence)
+
+1. **Launcher: `recipe_repos` config + `src/recipe_repos.rs`** (clone/pull
+   + merge + best-effort) + `repos` subcommand + the `repos.json`
+   registry + `archive`/`unarchive`. Unit tests with `file://` repos.
+2. **Launcher: `promote` subcommand** (`--dry-run`/`--apply`, the git mv,
+   the collision refusal). Unit tests with `file://` repos.
+3. **Launcher: `--image-source {repo,registry}`** (the hybrid pull). A
+   unit test for the tag + checksum verification.
+4. **Docker image** (`docker-images/sm121-addon/` + the entrypoint + the
+   supervisor re-sync) + a `make addon-image` target + the smoke test.
+5. **HA add-on** (`sm121-ha-integration/` restructured: `config.yaml` +
+   `Dockerfile` + the options schema) + the README (the install, the
+   network reachability, the single-launcher rule).
+6. **Recipe repos** (the operator creates `recipes-production` +
+   `recipes-playground` on `git.kilork.org`; a seed script copies the
+   current `recipes/` + `config.toml` + `docker-images/` into them).
+7. **Docs** (the AGENTS.md add-on section, the DEVELOPMENT.md
+   architecture note, the add-on README).
+
+## Out of scope (later phases)
+
+- HA **entities** (a thin custom integration over the add-on's `/api/…`
+  for power/serve/stop/health) — the dashboard (port 3100) is enough for
+  phase 1; the integration is a phase-2 nicety.
+- Auto-branching of the playground repo (a PR per recipe) — the operator
+  drives branches today; automation is later.
+- A web UI for the repo registry / promotion (the CLI is enough for phase 1).