浏览代码

add-on image: THIN — the Pi is the build host (HAOS has no Docker)

HAOS on the Pi can't build Docker images, so the add-on image carries
no sm121 binary: debian-slim + git/jq + the pinned Rust toolchain, and
the entrypoint compiles sm121 from the launcher repo on first boot
(sm121_launcher_url @ sm121_launcher_rev, default main), caching the
binary + cargo target dir in the add-on's data folder ($HOME). The arch
always matches, the binary never goes stale; first boot is a full
release build (sm121_build_jobs=2 so a Pi 4 doesn't OOM under HA).

- Dockerfile: single-stage thin image (context = docker-images/sm121-addon/)
- entrypoint: compile-if-needed step (rev stamp + CARGO_TARGET_DIR cache)
- config.yaml (sibling repo): sm121_launcher_url/_rev/_build_jobs options
- smoke-addon.sh: hermetic — file:// launcher checkout + persistent HOME
  (passed: compiled from the checkout, repo sync, /api/state 200)
- .dockerignore removed (the context no longer needs trimming)
- docs: AGENTS.md add-on section, add-on README, spec addendum
Alexander Korolev 1 周之前
父节点
当前提交
f0c300efec

+ 0 - 10
.dockerignore

@@ -1,10 +0,0 @@
-# Everything EXCEPT what the sm121-addon image build needs
-# (docker-images/sm121-addon/Dockerfile, context = repo root).
-# Keep src/ + locales/ + host-scripts/ + the cargo manifest + the entrypoint.
-*
-!Cargo.toml
-!Cargo.lock
-!src/
-!locales/
-!host-scripts/
-!docker-images/sm121-addon/entrypoint.sh

+ 24 - 14
AGENTS.md

@@ -780,21 +780,31 @@ the `sm121.checksum` label — a missing tag names the tag + hints to
 build/push first (or fall back to `--image-source repo`). Mutually
 exclusive with `--build-node`.
 
-**The add-on image** (`docker-images/sm121-addon/`, multi-stage — sm121
-is compiled in-container so the binary matches the container arch):
-`make addon-image` → `sm121-addon:latest`; `make addon-push` →
+**The add-on image** (`docker-images/sm121-addon/`, THIN — the Pi is the
+build host): `make addon-image` → `sm121-addon:latest` (debian-slim +
+git/jq + the pinned Rust toolchain, ~1.2 GB, no sm121 binary); the
+entrypoint **compiles sm121 from the launcher repo on first boot**
+(`sm121_launcher_url` default git.kilork.org/sm121/sm121-launcher,
+`sm121_launcher_rev` default main — set a commit to pin) and caches the
+binary + cargo target dir in the add-on's data folder ($HOME), so a
+reboot with an unchanged rev is fast and a rev change recompiles once.
+The arch always matches (no cross-toolchain, x86_64 HA hosts work with a
+multi-arch image push) and the binary never goes stale. First boot is a
+full release build (minutes on a Pi 5, longer on a Pi 4 —
+`sm121_build_jobs` defaults to 2 so a Pi 4 doesn't OOM under HA). This is
+the HAOS path (HAOS has no Docker to build with). `make addon-push` →
 `ghcr.io/kilork/sm121-addon:latest`; smoke test `scripts/smoke-addon.sh`
-(entrypoint + `file://` recipe repo + `curl /api/state`). The entrypoint
-writes the effective config from the add-on options (env), drops the SSH
-key (base64 option), `sm121 repos sync`, then `sm121 web --port 3100
---bind 0.0.0.0` (a bearer token is required) with a background re-sync
-loop (`SM121_REPO_RESYNC_MINUTES`). The HA add-on manifest
-(`config.yaml` + README) lives in the sibling **sm121-ha-integration**
-repo — its `custom_components/` custom-integration prototype is
-superseded (entities are a phase-2 nicety over the add-on's REST API).
-Per-install credentials that can't be flat options (e.g. a `[mikrotik]`
-block with its password) go in the `sm121_config_extra` option — raw
-TOML appended to the generated config, kept out of the log.
+(entrypoint + `file://` recipe repo + `file://` launcher checkout +
+curl /api/state). The entrypoint writes the effective config from the
+add-on options (env), drops the SSH key (base64 option), `sm121 repos
+sync`, then `sm121 web --port 3100 --bind 0.0.0.0` (a bearer token is
+required) with a background re-sync loop (`SM121_REPO_RESYNC_MINUTES`).
+The HA add-on manifest (`config.yaml` + README) lives in the sibling
+**sm121-ha-integration** repo — its `custom_components/` custom-integration
+prototype is superseded (entities are a phase-2 nicety over the add-on's
+REST API). Per-install credentials that can't be flat options (e.g. a
+`[mikrotik]` block with its password) go in the `sm121_config_extra`
+option — raw TOML appended to the generated config, kept out of the log.
 
 **Single-launcher rule** — the per-recipe run ledger (`.lock` in the
 recipe repo) is the serialization point: a second launcher that serves

+ 6 - 6
Makefile

@@ -41,16 +41,16 @@ fmt:
 # always matches the container's arch; .dockerignore trims the context).
 # Smoke test: scripts/smoke-addon.sh.
 addon-image:
-	docker build -t sm121-addon:latest -f docker-images/sm121-addon/Dockerfile .
+	docker build -t sm121-addon:latest -f docker-images/sm121-addon/Dockerfile docker-images/sm121-addon/
 
 # Push the add-on image to the [registry] base (the HA add-on's config.yaml
 # references it). Requires `docker login ghcr.io`.
-# NOTE: single-arch (the build host's — aarch64 on the Mac / DGX nodes,
-# matching the Pi's arch). For a multi-arch manifest (x86_64 too) use:
+# The image is THIN (no sm121 binary — the entrypoint compiles it on the Pi
+# from the launcher repo, so the arch always matches). Built on an aarch64
+# host for the Pi; for a multi-arch manifest (x86_64 too) use:
 #   docker buildx build --platform linux/aarch64,linux/x86_64 \
-#     -t ghcr.io/kilork/sm121-addon:latest --push -f docker-images/sm121-addon/Dockerfile .
-# (needs qemu binfmt registered on the builder; the in-container Rust
-# compile runs under emulation for the foreign arch — slow but works).
+#     -t ghcr.io/kilork/sm121-addon:latest --push -f docker-images/sm121-addon/Dockerfile docker-images/sm121-addon/
+# (needs qemu binfmt on the builder).
 addon-push:
 	docker tag sm121-addon:latest ghcr.io/kilork/sm121-addon:latest
 	docker push ghcr.io/kilork/sm121-addon:latest

+ 27 - 24
docker-images/sm121-addon/Dockerfile

@@ -1,35 +1,38 @@
-# sm121 HA add-on image — the launcher as a 24/7 Home Assistant add-on
-# (specs/tech-architecture/ha-addon-recipe-repos.md).
+# sm121 HA add-on image — THIN: bakes in the Rust toolchain; the
+# entrypoint compiles sm121 from the launcher repo on first boot (cached
+# in the add-on's data folder). The Pi IS the build host: always the right
+# arch, the binary never goes stale, and the image carries no sm121
+# binary at all (specs/tech-architecture/ha-addon-recipe-repos.md —
+# "build on ha.lan" variant for HAOS, which has no Docker).
 #
-# Multi-stage: the launcher is COMPILED IN-CONTAINER (stage 1) so the
-# binary always matches the container's architecture (no cross-toolchain
-# on the host). Build context = the repo root (trimmed by .dockerignore
-# to src/ + locales/ + host-scripts/ + the cargo manifest).
+#   make addon-image      # context = docker-images/sm121-addon/
+#   scripts/smoke-addon.sh
 #
-#   make addon-image
-#   scripts/smoke-addon.sh     # entrypoint + file:// repo + curl /api/state
+# The image is arch-generic EXCEPT the baked rustup toolchain (built for
+# the build host's arch — build it on an aarch64 host for the Pi; a
+# multi-arch buildx push covers x86_64 HA hosts too).
 
-# --- stage 1: compile ----------------------------------------------------------
-# (matches the repo toolchain — edition 2024 + let-chains need rust ≥ 1.88)
-FROM rust:1.98-bookworm AS build
-WORKDIR /src
-COPY Cargo.toml Cargo.lock ./
-COPY src/ src/
-COPY locales/ locales/
-COPY host-scripts/ host-scripts/
-RUN cargo build --release
-
-# --- stage 2: the add-on -------------------------------------------------------
 FROM debian:bookworm-slim
 
-# git (recipe-repo sync) + jq (env → config generation) + ca-certs (https)
+# git (launcher-repo fetch + recipe-repo sync) + jq (env → config) +
+# ca-certs (https) + gcc/libc6-dev (ring's C backend, via russh)
 RUN apt-get update \
- && apt-get install -y --no-install-recommends git ca-certificates jq \
+ && apt-get install -y --no-install-recommends \
+      git ca-certificates jq curl gcc libc6-dev \
  && rm -rf /var/lib/apt/lists/*
 
-COPY --from=build /src/target/release/sm121 /usr/local/bin/sm121
-COPY docker-images/sm121-addon/entrypoint.sh /entrypoint.sh
-RUN chmod 755 /usr/local/bin/sm121 /entrypoint.sh
+# Rust toolchain, pinned (edition 2024 + let-chains need rust ≥ 1.88; the
+# launcher's Cargo.toml pins the exact version). `minimal` profile — no
+# docs/rustfmt/clippy (the Pi only builds, never lints).
+ENV RUSTUP_HOME=/usr/local/rustup \
+    CARGO_HOME=/usr/local/cargo \
+    PATH=/usr/local/cargo/bin:$PATH
+RUN curl -sSf https://sh.rustup.rs \
+    | sh -s -- -y --profile minimal --default-toolchain 1.98.1 \
+ && rustup toolchain list
+
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod 755 /entrypoint.sh
 
 ENV SM121_WEB_PORT=3100
 EXPOSE 3100

+ 36 - 1
docker-images/sm121-addon/entrypoint.sh

@@ -23,6 +23,12 @@
 #   SM121_CONFIG_EXTRA        raw TOML appended verbatim (per-install
 #                             credentials that must not live in the shared
 #                             repo, e.g. a [mikrotik] block with its password)
+#   SM121_LAUNCHER_URL        launcher repo (default git.kilork.org/sm121/
+#                             sm121-launcher) — sm121 is COMPILED from it on
+#                             first boot (the Pi is the build host)
+#   SM121_LAUNCHER_REV        rev to compile (default main; a commit hash
+#                             pins the binary)
+#   SM121_BUILD_JOBS          cargo -j (default 2 — a Pi 4 must not OOM)
 #   SM121_REPO_RESYNC_MINUTES background re-sync interval (default 10)
 #   SM121_WEB_PORT            dashboard port (default 3100)
 #   SM121_WEB_BIND            dashboard bind (default 0.0.0.0)
@@ -85,8 +91,37 @@ elif [ -n "${SM121_SSH_KEY:-}" ] && [ -f "$SM121_SSH_KEY" ]; then
   chmod 600 "$KEY_PATH"
 fi
 
+# --- 2b. compile sm121 if needed (the Pi is the build host) ------------------
+# The launcher source comes from the launcher repo (SM121_LAUNCHER_URL @
+# SM121_LAUNCHER_REV). The binary + a rev stamp + the cargo target cache
+# live in the add-on's data folder ($HOME — HA sets it to the add-on's data
+# dir; falls back to /root), so a reboot with an unchanged rev is fast and
+# a rev change recompiles once. First boot is slow (a full release build:
+# minutes on a Pi 5, longer on a Pi 4) — the add-on just shows "starting".
+HOME="${HOME:-/root}"
+SM121_BIN="$HOME/.sm121/bin/sm121"
+LAUNCHER_URL="${SM121_LAUNCHER_URL:-https://git.kilork.org/sm121/sm121-launcher.git}"
+LAUNCHER_REV="${SM121_LAUNCHER_REV:-main}"
+STAMP="$HOME/.sm121/rev"
+if [ ! -x "$SM121_BIN" ] || [ "$(cat "$STAMP" 2>/dev/null)" != "$LAUNCHER_REV" ]; then
+  echo "sm121: compiling from $LAUNCHER_URL @ $LAUNCHER_REV (first boot / rev changed)"
+  mkdir -p "$HOME/.sm121/bin"
+  SRC="$(mktemp -d)"
+  case "$LAUNCHER_REV" in
+    main|master) git clone -q --depth 1 --branch "$LAUNCHER_REV" "$LAUNCHER_URL" "$SRC" ;;
+    *) git clone -q "$LAUNCHER_URL" "$SRC" && git -C "$SRC" checkout -q "$LAUNCHER_REV" ;;
+  esac
+  CARGO_TARGET_DIR="$HOME/.sm121/target" \
+  CARGO_BUILD_JOBS="${SM121_BUILD_JOBS:-2}" \
+  cargo build --release --manifest-path "$SRC/Cargo.toml"
+  cp "$HOME/.sm121/target/release/sm121" "$SM121_BIN"
+  printf '%s' "$LAUNCHER_REV" > "$STAMP"
+  rm -rf "$SRC"
+  echo "sm121: built ($LAUNCHER_REV) → $SM121_BIN"
+fi
+
 # --- 3 + 4. sync + web, with the re-sync supervisor ----------------------------
-SM121="sm121 --config $CONFIG"
+SM121="$SM121_BIN --config $CONFIG"
 $SM121 repos sync || echo "warning: initial repo sync failed (the supervisor retries)"
 
 INTERVAL="${SM121_REPO_RESYNC_MINUTES:-10}"

+ 19 - 5
scripts/smoke-addon.sh

@@ -1,8 +1,12 @@
 #!/usr/bin/env bash
 # Smoke test for the sm121 add-on image (specs/tech-architecture/
-# ha-addon-recipe-repos.md, step 3): run the entrypoint with a `file://`
-# recipe repo (mounted) + a token, and assert the dashboard answers on
-# 3100 (curl /api/state).
+# ha-addon-recipe-repos.md): run the entrypoint with a `file://` recipe
+# repo (mounted) + a token, and assert the dashboard answers on 3100
+# (curl /api/state).
+#
+# The thin image compiles sm121 from the launcher repo on first boot —
+# hermetically, from THIS checkout via file:// (SM121_LAUNCHER_URL), with
+# the cargo target dir persisted in a volume (re-runs are incremental).
 #
 #   scripts/smoke-addon.sh [image]     (default sm121-addon:latest)
 set -euo pipefail
@@ -22,18 +26,28 @@ echo 'engine = "vllm"' > "$REPO/recipes/demo/recipe.toml"
 git -C "$REPO" add -A
 git -C "$REPO" -c user.email=t@sm121.test -c user.name=sm121-test commit -qm init
 
+# Persistent HOME (the add-on data folder in HA; here: the sm121 binary +
+# the cargo target cache, so re-runs don't rebuild the world).
+HOMEV="$(pwd)/target/smoke-addon-home"
+mkdir -p "$HOMEV"
+
 CID="$(docker run -d \
   -p 3100:3100 \
   -v "$REPO":/repos:ro \
+  -v "$HOMEV":/root \
+  -v "$(pwd)":/src-launcher:ro \
   -e SM121_RECIPE_REPOS='[{"url":"file:///repos"}]' \
+  -e SM121_LAUNCHER_URL="file:///src-launcher" \
+  -e SM121_LAUNCHER_REV=HEAD \
   -e SM121_SSH_USER=u \
   -e SM121_WEB_TOKEN="$TOKEN" \
   "$IMG")"
 trap 'docker rm -f "$CID" >/dev/null 2>&1 || true; rm -rf "$REPO"' EXIT
 
-# Wait for the dashboard (up to 30 s — the first sync is a clone).
+# Wait for the dashboard (up to 8 min — the first run compiles sm121 from
+# source; a warm cache is seconds).
 ok=""
-for _ in $(seq 1 30); do
+for _ in $(seq 1 480); do
   if curl -sf -H "Authorization: Bearer $TOKEN" \
       http://127.0.0.1:3100/api/state >/dev/null 2>&1; then
     ok=1

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

@@ -208,3 +208,20 @@ add-on's base config.
 - 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).
+
+## Addendum (2026-10-11): the HAOS build variant — the Pi is the build host
+
+The HA on the target is **HAOS on a Raspberry Pi** — no Docker on the
+host, so the image cannot be built there classically. The add-on image
+is therefore **thin** (debian-slim + git/jq + the pinned Rust toolchain,
+no sm121 binary): the entrypoint **compiles sm121 from the launcher repo
+on first boot** (`sm121_launcher_url` @ `sm121_launcher_rev`, default
+`main`; a commit hash pins the binary), caching the binary + the cargo
+target dir in the add-on's data folder ($HOME). Properties: the arch
+always matches (x86_64 HA hosts work with a multi-arch image push), the
+binary never goes stale, and only the thin image needs a registry push.
+First boot is a full release build (minutes on a Pi 5, longer on a Pi 4;
+`sm121_build_jobs` defaults to 2 so a Pi 4 doesn't OOM under HA).
+Supersedes the original multi-stage (binary baked in) image for the
+add-on flow; the smoke test compiles hermetically from a `file://`
+launcher checkout.