|
|
@@ -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).
|