From e000bea79370ea8dad665cee98b6f22b13fab0bc Mon Sep 17 00:00:00 2001 From: wms2537 <51080539+wms2537@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:06:08 +0800 Subject: [PATCH 1/2] fix(scripts,docs): make a fresh clone actually work before going public Findings from a production-readiness pass done against a fresh clone of this repo, i.e. what an arriving contributor actually gets. Scripts (the entry path both README and CONTRIBUTING point at): - run_stack.sh resolved paths from a hardcoded /home/soh/cctv and had no precondition checks, so elsewhere it started nothing yet still printed 'stack up: ...' and slept 1800s. Repo-relative now, honours HELDAR_DATA_DIR, and fails loudly on a missing core binary / MediaMTX / dashboard deps. Same hardcoded root removed from smoke_web.sh and every validate_*.sh; CAM= now overrides the camera they exercise. - setup_mediamtx.sh used GNU-only 'grep -oP' and always fetched linux_amd64; it now detects OS/arch and parses the tag portably (MEDIAMTX_TAG= pins one). - Publish authorization moved to the kernel (authMethod: http -> /internal/mediamtx-auth), so an ffmpeg publisher started before heldar-core gets a 401 and exits. validate.sh, smoke_web.sh and e2e_stack.sh all started publishers first, so they were validating cameras that never streamed. They now publish after the API is healthy; validate.sh aborts if its camera dies. - e2e_stack.sh additionally ran the core on :8011 while mediamtx.yml pins the auth callback to :8000, so MediaMTX denied every publish and read: it now starts MediaMTX from a port-adjusted copy. Plus two macOS portability fixes (bash 3.2 has no negative array subscripts; fuser has no -k). Docs: LICENSING, DESIGN-PRINCIPLES #8, the open-core and module-system pages (with es/zh-Hans), REMOTE-ACCESS, PRODUCTION, README and the heldar-server composition-root comments still described the retired generated-tree model -- a public repo generated from a private monorepo, with main.rs substituted per build. That directly contradicted CONTRIBUTING and would tell a contributor their PR gets regenerated away. They now describe the real model, and ARCHITECTURE.md no longer points at a docs/adr/ path absent from this repo. Verified on a fresh clone: fmt, clippy -D warnings, 288 tests, read-seam lint, open build and the dashboard build all pass; validate.sh records 7 segments at 15fps and cuts a clip; the e2e stack brings both cameras to 'recording'. Signed-off-by: wms2537 <51080539+wms2537@users.noreply.github.com> --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 41 +++++++++++++++- CONTRIBUTING.md | 4 +- LICENSING.md | 6 ++- README.md | 5 +- crates/heldar-server/src/main.rs | 9 ++-- crates/heldar-server/src/verticals.rs | 9 ++-- docs/DESIGN-PRINCIPLES.md | 10 ++-- docs/PRODUCTION.md | 3 +- docs/REMOTE-ACCESS.md | 6 +-- scripts/e2e_stack.sh | 48 +++++++++++++------ scripts/run_stack.sh | 29 ++++++++--- scripts/setup_mediamtx.sh | 37 ++++++++++++-- scripts/smoke_web.sh | 14 +++--- scripts/synth_camera.sh | 4 ++ scripts/validate.sh | 48 +++++++++++-------- scripts/validate_ai.sh | 11 +++-- scripts/validate_entry.sh | 7 ++- scripts/validate_movement.sh | 9 ++-- scripts/validate_rbac.sh | 7 ++- scripts/validate_search.sh | 9 ++-- scripts/validate_zones.sh | 7 ++- website/docs/concepts/open-core.md | 19 ++++---- website/docs/develop/module-system.md | 7 +-- .../current/concepts/open-core.md | 22 +++++---- .../current/concepts/open-core.md | 4 +- 26 files changed, 262 insertions(+), 117 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1e60ddf..d892edd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2022,8 +2022,8 @@ A deployment is normally behind **CGNAT** (shared public IPv4, no inbound port-f useless), so the only thing that reaches it is the node **dialing out**. Remote access is therefore **browser-based WebRTC**: the box dials OUT to a **signaling + TURN service** in the control plane, NAT traversal is negotiated over ICE, and a browser viewer plays live video over **WHEP** (served by -MediaMTX). Full rationale and the deploy recipes live in `docs/REMOTE-ACCESS.md` and -`docs/adr/0003-webrtc-remote-access.md`; this section records the architecture. +MediaMTX). The deploy recipes live in `docs/REMOTE-ACCESS.md`; this section is the architecture of +record (originally decided as ADR 0003). **Two layers, kept separate.** *Reachability* is WebRTC: the box and the browser exchange SDP/ICE through the control-plane **signaling** endpoint, then establish a direct peer connection whenever diff --git a/CHANGELOG.md b/CHANGELOG.md index 069324d..facf17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_Nothing yet._ +### Fixed + +- **The from-source quickstart works in any clone.** `scripts/run_stack.sh` resolved its paths from a + hardcoded `/home/soh/cctv`, so for everyone else it started nothing — and, having no precondition + checks, still printed `stack up: …` and slept for 30 minutes. It now resolves paths relative to the + script, honours `HELDAR_DATA_DIR`, and fails loudly on a missing binary, MediaMTX, or dashboard + deps. The same hardcoded root is gone from `smoke_web.sh` and every `validate_*.sh`, whose reports + now land in the repo's `data/`; the camera they exercise is overridable with `CAM=`. + +- **`scripts/setup_mediamtx.sh` runs outside Linux/x86.** It parsed the release tag with `grep -oP` + (GNU-only — it failed outright on macOS/BSD) and always downloaded `linux_amd64`. It now detects + OS/arch (linux + darwin; amd64/arm64/armv7/armv6) and parses the tag portably. `MEDIAMTX_TAG=` + pins a release. + +- **Synthetic-camera harnesses publish after the core, not before.** Since publish authorization moved + to the kernel (`authMethod: http` → `/internal/mediamtx-auth`), starting an ffmpeg publisher before + heldar-core is up gets a 401 and the publisher exits immediately — so `validate.sh`, `smoke_web.sh` + and the Playwright `e2e_stack.sh` were all exercising cameras that never streamed. The publishers now + start after the API is healthy, and `validate.sh` aborts if its camera dies. + +- **The Playwright e2e stack actually records.** `e2e_stack.sh` runs the core on `:8011`, but + `mediamtx.yml` pins the kernel auth callback to `:8000`, so MediaMTX asked a dead port and denied + every publish *and* read. It now starts MediaMTX from a port-adjusted copy of the config. Two further + portability fixes: `wait "${PIDS[-1]}"` needs bash ≥ 4.3 and aborted under `set -u` on macOS's bash + 3.2 (it now waits on the core PID), and the `fuser -k` port cleanup falls back to `lsof` where + `fuser` has no `-k`. + +### Documentation + +- Docs no longer describe the retired generated-tree model. `LICENSING.md`, `DESIGN-PRINCIPLES.md` #8, + the open-core and module-system pages (plus their `es`/`zh-Hans` translations), `REMOTE-ACCESS.md`, + `PRODUCTION.md` and the `heldar-server` composition-root comments said the public repo was *generated + from a private monorepo* and that `main.rs` was substituted per build — which contradicted + CONTRIBUTING and would have told a contributor their PR gets regenerated away. They now describe the + real model: this repo is the source of truth, and a private product composes its own binary against + `heldar_server::run(impl Verticals)`. +- `ARCHITECTURE.md` §21 no longer points at a `docs/adr/` path that does not exist in this repo. +- README/CONTRIBUTING dev setup now includes the dashboard `npm ci` step that `run_stack.sh` requires, + and the README no longer describes the production overlay as switching to a private image (it is an + open hardening overlay). ## [0.3.1] - 2026-07-31 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 450c1b8..f658408 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,12 +23,14 @@ Prerequisites: Rust (via `rustup`), FFmpeg + ffprobe on `PATH`, Node.js (fronten ```bash rustup update cargo build --workspace +cd apps/web && npm ci && cd - # dashboard deps scripts/setup_mediamtx.sh # fetch the MediaMTX live-view gateway scripts/run_stack.sh # MediaMTX + core (:8000) + web (Vite) ``` The per-stage `scripts/validate_*.sh` scripts exercise each capability end-to-end against a running -stack and write reports to `data/`. +stack and write reports to `data/` (override with `HELDAR_DATA_DIR`). Those that need a registered +camera default to `cam_192_168_0_2`; point them at yours with `CAM=`. ## Quality bar (CI will check these) diff --git a/LICENSING.md b/LICENSING.md index cae7ff9..81abd97 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -34,5 +34,7 @@ layer their specifics on top. They plug in only through the kernel's public seam primitive. A deployment is **composed** from the open kernel + open apps + whichever proprietary app crates that client needs (single-tenant per deployment). -See `ARCHITECTURE.md` for the seams. The public `heldar` repo is generated from the private monorepo -(this file is the licensing statement of record), and the open crates are published to crates.io. +See `ARCHITECTURE.md` for the seams. This repository **is** the source of truth for everything +Apache-2.0 above (this file is the licensing statement of record); the open crates are published to +crates.io from here. Proprietary products live in their own private repositories and consume the +published crates through the documented seams — they are never merged into this tree. diff --git a/README.md b/README.md index 6fef9d9..038d4ae 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ curl -fsSL https://heldar.swmengappdev.workers.dev/install.sh | sh Pulls the prebuilt **OPEN** images (kernel + generic apps) and starts MediaMTX + core + web — the dashboard is then at `http://localhost:8080`. Add the reference AI worker with `--profile ai`; update -with `docker compose pull`. Production (private full image, auth, secrets, TLS) uses the overlay -`docker compose -f deploy/compose.yml -f deploy/compose.prod.yml up -d` — see +with `docker compose pull`. For production (auth on, secure cookies, strict boot guardrails) layer the +hardening overlay: `docker compose -f deploy/compose.yml -f deploy/compose.prod.yml up -d` — see [`docs/PRODUCTION.md`](docs/PRODUCTION.md). For a flashed DVR/appliance, use the native-systemd image instead (`make appliance-image`, [`infra/systemd/`](infra/systemd/)). @@ -51,6 +51,7 @@ dashboard; Python 3 for the AI worker. rustup update # the project tracks latest stable cargo build --workspace cp .env.example .env # defaults work out of the box; never commit .env +(cd apps/web && npm ci) # dashboard dependencies scripts/setup_mediamtx.sh # fetch the MediaMTX live-view gateway scripts/run_stack.sh # MediaMTX + core (http://localhost:8000) + web (Vite on :5173) ``` diff --git a/crates/heldar-server/src/main.rs b/crates/heldar-server/src/main.rs index 630e0ae..ba2640a 100644 --- a/crates/heldar-server/src/main.rs +++ b/crates/heldar-server/src/main.rs @@ -1,7 +1,8 @@ -//! The composed `heldar-core` binary for THIS workspace: the library server plus the in-tree -//! vertical composition (`verticals.rs` — the real proprietary module here; a no-op stub in the -//! open repo, so this file is identical across both). An out-of-tree overlay builds its own bin -//! against `heldar_server::run` instead of this one. +//! The composed `heldar-core` binary: the library server (`heldar_server::run`) plus this +//! workspace's vertical composition (`verticals.rs`, a no-op here — the open build links no +//! proprietary code). A private product does NOT fork this file: it depends on this crate (by git +//! tag — the composition crate is `publish = false`) and calls `heldar_server::run` with its own +//! `Verticals` implementation. use axum::Router; use heldar_kernel::modules::ModuleManifest; diff --git a/crates/heldar-server/src/verticals.rs b/crates/heldar-server/src/verticals.rs index cc6c85e..b3f8bb7 100644 --- a/crates/heldar-server/src/verticals.rs +++ b/crates/heldar-server/src/verticals.rs @@ -1,8 +1,9 @@ -//! Proprietary vertical composition seam — a NO-OP stub in the open repo. +//! Vertical composition for this workspace — deliberately a NO-OP. //! -//! `main.rs` calls these functions unconditionally. In the open build they do nothing and reference -//! no proprietary crate. The private workspace replaces this file with the real composition module -//! (the proprietary verticals) — `main.rs` is identical across both repos. +//! `main.rs` adapts these functions into a [`heldar_server::Verticals`] implementation. Nothing here +//! references a proprietary crate, so the reference `heldar-core` binary links none. This file is +//! also the smallest worked example of the seam: a private product implements the same four hooks +//! in its own repository and passes them to `heldar_server::run`. use axum::Router; use heldar_kernel::modules::ModuleManifest; diff --git a/docs/DESIGN-PRINCIPLES.md b/docs/DESIGN-PRINCIPLES.md index de73858..6c67724 100644 --- a/docs/DESIGN-PRINCIPLES.md +++ b/docs/DESIGN-PRINCIPLES.md @@ -44,10 +44,12 @@ explicitly and why. one. Deploys keep a rollback binary. A cleared runtime override reverts to the env default. Prefer changes that can be undone without a data migration. -8. **Open-core discipline.** The public repo is *generated* from the private monorepo by a scrubbing - step; proprietary code and names never reach it, and secrets are never committed (reference - credentials by `file:line` + type only). If a change could leak proprietary material to the open - tree, the generator must strip it and the leak-gate must catch it. +8. **Open-core discipline.** This repository is the source of truth for the open platform, and it is + developed in the open — the commit that lands here is the commit that ships. Proprietary code and + client names therefore must never be written into it in the first place, and secrets are never + committed (reference credentials by `file:line` + type only). Anything vertical- or + client-specific belongs in its own private repository, consuming these crates through the + published seams. 9. **Compose, don't couple.** Apps plug into the kernel through narrow, named seams — a `DetectionConsumer`, a `Router` merge, a self-installed schema (`schema::init`). Adding a diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index c135709..07ecfef 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -26,8 +26,7 @@ or an empty dial-out bearer (`HELDAR_CP_TOKEN`) while a rendezvous is configured Each tagged release attaches static `heldar-core` binaries (x86_64 + aarch64). To upgrade a box in place: - REPO=Straits-AI/heldar # open build (self-hosters) - # REPO=Straits-AI/heldar-proprietary # full/licensed build + REPO=Straits-AI/heldar # licensed/full deployments substitute their own release repo ARCH=$(uname -m) # x86_64 or aarch64 V=vX.Y.Z curl -fsSLO "https://github.com/$REPO/releases/download/$V/heldar-core-$V-$ARCH-linux-musl" diff --git a/docs/REMOTE-ACCESS.md b/docs/REMOTE-ACCESS.md index 15a48d8..d1ab0e1 100644 --- a/docs/REMOTE-ACCESS.md +++ b/docs/REMOTE-ACCESS.md @@ -5,9 +5,9 @@ common case for home/small-site internet: a shared public IPv4, no inbound port- useless). This is an **open kernel** capability: every deployment of the Apache-2.0 kernel gets private remote viewing out of the box. -Remote access is **WebRTC-primary, browser-based** — see [`ARCHITECTURE.md`](../ARCHITECTURE.md) §21 -(the WebRTC model; the full design of record is ADR 0003, `docs/adr/0003-webrtc-remote-access.md` in -the private monorepo). All phases are **shipped** (P1 live video → P2 universal reach → P3 the full dashboard; see +Remote access is **WebRTC-primary, browser-based** — the design of record is +[`ARCHITECTURE.md`](../ARCHITECTURE.md) §21, which carries the full WebRTC model (originally decided +as ADR 0003). All phases are **shipped** (P1 live video → P2 universal reach → P3 the full dashboard; see _Status & phasing_ below). The optional self-hoster **overlay** paths (Recipes A/B) remain available for operators who prefer full-L3 reach over the hosted rendezvous. Hardening a deployment for the public internet: [`docs/PRODUCTION.md`](PRODUCTION.md). diff --git a/scripts/e2e_stack.sh b/scripts/e2e_stack.sh index 410af5b..d325042 100755 --- a/scripts/e2e_stack.sh +++ b/scripts/e2e_stack.sh @@ -24,25 +24,27 @@ trap cleanup EXIT INT TERM # Free the ports + kill leftovers from a previous run that may have been killed before its trap ran, # so every boot starts clean (the core port + MediaMTX's RTSP/API ports, and the synthetic publishers). -fuser -k "${PORT}/tcp" 8554/tcp 9997/tcp 2>/dev/null || true +# `fuser -k` is Linux-only (macOS fuser takes no -k), so prefer lsof when it is available. +if command -v lsof >/dev/null 2>&1; then + for p in "$PORT" 8554 9997; do + lsof -ti "tcp:$p" 2>/dev/null | xargs -r kill -9 2>/dev/null || true + done +else + fuser -k "${PORT}/tcp" 8554/tcp 9997/tcp 2>/dev/null || true +fi pkill -9 -f 'rtsp://127.0.0.1:8554/cam_e2e_' 2>/dev/null || true sleep 1 echo "[e2e_stack] MediaMTX" -"$MTX" "$ROOT/infra/mediamtx/mediamtx.yml" >"$LOG/mediamtx.log" 2>&1 & PIDS+=($!) +# The shipped config pins the kernel auth callback to :8000, but this stack runs the core on $PORT so +# it never collides with a dev core. Without repointing it, MediaMTX asks a dead port and denies every +# publish AND read with a 401 — cameras silently stay offline. Generate a port-adjusted copy. +MTX_CFG="$DATA/mediamtx.yml" +sed "s|http://127.0.0.1:8000/internal/mediamtx-auth|http://127.0.0.1:${PORT}/internal/mediamtx-auth|" \ + "$ROOT/infra/mediamtx/mediamtx.yml" >"$MTX_CFG" +"$MTX" "$MTX_CFG" >"$LOG/mediamtx.log" 2>&1 & PIDS+=($!) sleep 2 -echo "[e2e_stack] $NCAMS synthetic cameras" -for i in $(seq 1 "$NCAMS"); do - # testsrc has a built-in moving pattern + frame counter (motion for the AI task); no drawtext, since - # that filter needs libfreetype which isn't in every ffmpeg build. - ffmpeg -nostdin -hide_banner -loglevel error -re \ - -f lavfi -i "testsrc=size=640x360:rate=10" \ - -c:v libx264 -preset ultrafast -tune zerolatency -g 20 -pix_fmt yuv420p \ - -f rtsp -rtsp_transport tcp "rtsp://127.0.0.1:8554/cam_e2e_${i}" >"$LOG/cam_${i}.log" 2>&1 & PIDS+=($!) -done -sleep 3 - echo "[e2e_stack] core (isolated DB under $DATA, port $PORT)" HELDAR_DATABASE_URL="sqlite://$DATA/heldar.db" \ HELDAR_DATA_DIR="$DATA" \ @@ -52,12 +54,26 @@ HELDAR_DEFAULT_SEGMENT_SECONDS=5 \ HELDAR_INDEXER_INTERVAL_S=3 \ HELDAR_HEALTH_INTERVAL_S=5 \ HELDAR_AI_ENABLED=true HELDAR_DEFAULT_AI_FPS=2 \ -"$CORE" >"$LOG/core.log" 2>&1 & PIDS+=($!) +"$CORE" >"$LOG/core.log" 2>&1 & CORE_PID=$!; PIDS+=($CORE_PID) # wait for the API for _ in $(seq 1 40); do curl -fsS "$API/healthz" >/dev/null 2>&1 && break; sleep 1; done curl -fsS "$API/healthz" >/dev/null 2>&1 || { echo "[e2e_stack] core did not start"; tail -20 "$LOG/core.log"; exit 1; } +# Publishers start AFTER the core: MediaMTX delegates publish authorization to the kernel +# (`authMethod: http` -> /internal/mediamtx-auth), so publishing earlier is denied with a 401 and +# every ffmpeg exits immediately — leaving the e2e suite running against cameras that never stream. +echo "[e2e_stack] $NCAMS synthetic cameras" +for i in $(seq 1 "$NCAMS"); do + # testsrc has a built-in moving pattern + frame counter (motion for the AI task); no drawtext, since + # that filter needs libfreetype which isn't in every ffmpeg build. + ffmpeg -nostdin -hide_banner -loglevel error -re \ + -f lavfi -i "testsrc=size=640x360:rate=10" \ + -c:v libx264 -preset ultrafast -tune zerolatency -g 20 -pix_fmt yuv420p \ + -f rtsp -rtsp_transport tcp "rtsp://127.0.0.1:8554/cam_e2e_${i}" >"$LOG/cam_${i}.log" 2>&1 & PIDS+=($!) +done +sleep 3 + echo "[e2e_stack] registering $NCAMS cameras" for i in $(seq 1 "$NCAMS"); do curl -fsS -X POST "$API/api/v1/cameras" -H 'content-type: application/json' -d "{ @@ -71,4 +87,6 @@ curl -fsS -X POST "$API/api/v1/cameras/cam_e2e_1/ai-tasks" -H 'content-type: app -d '{"task_type":"motion","fps":2,"width":480,"enabled":true,"config":{"threshold":0.0008,"pixel_delta":6}}' >/dev/null 2>&1 || true echo "[e2e_stack] ready: $NCAMS cameras on $API (dashboard served). Waiting…" -wait "${PIDS[-1]}" +# Foreground on the core (Playwright's `webServer` keeps the stack alive). Not `${PIDS[-1]}`: +# negative array subscripts need bash >= 4.3 and macOS ships bash 3.2, where that aborts under `set -u`. +wait "$CORE_PID" diff --git a/scripts/run_stack.sh b/scripts/run_stack.sh index 70e6497..2ef815a 100755 --- a/scripts/run_stack.sh +++ b/scripts/run_stack.sh @@ -1,9 +1,22 @@ #!/usr/bin/env bash # Run the full Heldar stack (MediaMTX + core + Vite dashboard) for interactive/browser testing. # Stays up for up to 30 minutes, then auto-stops. Conservative recording limits for the dev host. +# +# Paths are resolved from this script's location, so it works in any clone. Override the data +# directory with HELDAR_DATA_DIR=... and the auto-stop with STACK_TTL_SECS=... set -u -ROOT=/home/soh/cctv -LOG="$ROOT/data/stack_logs"; mkdir -p "$LOG" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MTX="$ROOT/infra/mediamtx/mediamtx" +CORE="$ROOT/target/debug/heldar-core" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" +TTL="${STACK_TTL_SECS:-1800}" + +# Fail loudly on a missing prerequisite — a half-up stack that prints "stack up" is worse than an error. +[ -x "$MTX" ] || { echo "MediaMTX missing at $MTX — run: scripts/setup_mediamtx.sh" >&2; exit 1; } +[ -x "$CORE" ] || { echo "core not built at $CORE — run: cargo build --workspace" >&2; exit 1; } +[ -d "$ROOT/apps/web/node_modules" ] || { echo "dashboard deps missing — run: cd apps/web && npm ci" >&2; exit 1; } + +LOG="$DATA/stack_logs"; mkdir -p "$LOG" MTX_PID=""; CORE_PID=""; VITE_PID="" cleanup() { [ -n "$VITE_PID" ] && kill "$VITE_PID" 2>/dev/null @@ -14,19 +27,21 @@ cleanup() { } trap cleanup EXIT TERM INT -"$ROOT/infra/mediamtx/mediamtx" "$ROOT/infra/mediamtx/mediamtx.yml" >"$LOG/mediamtx.log" 2>&1 & +"$MTX" "$ROOT/infra/mediamtx/mediamtx.yml" >"$LOG/mediamtx.log" 2>&1 & MTX_PID=$! sleep 2 -HELDAR_DATA_DIR="$ROOT/data" \ +HELDAR_DATA_DIR="$DATA" \ HELDAR_MAX_RECORDINGS_GB=3 \ HELDAR_DEFAULT_RETENTION_HOURS=2 \ HELDAR_LOG="info,heldar_core=info" \ -"$ROOT/target/debug/heldar-core" >"$LOG/core.log" 2>&1 & +"$CORE" >"$LOG/core.log" 2>&1 & CORE_PID=$! ( cd "$ROOT/apps/web" && npm run dev >"$LOG/vite.log" 2>&1 ) & VITE_PID=$! -echo "stack up: mediamtx=$MTX_PID core=$CORE_PID vite=$VITE_PID (auto-stop in 1800s)" -sleep 1800 +echo "stack up: mediamtx=$MTX_PID core=$CORE_PID vite=$VITE_PID (auto-stop in ${TTL}s)" +echo " core: http://localhost:8000" +echo " dashboard: http://localhost:5173 (logs in $LOG)" +sleep "$TTL" diff --git a/scripts/setup_mediamtx.sh b/scripts/setup_mediamtx.sh index 9f89f13..0c8ea6c 100755 --- a/scripts/setup_mediamtx.sh +++ b/scripts/setup_mediamtx.sh @@ -1,13 +1,40 @@ #!/usr/bin/env bash # Download the MediaMTX binary into infra/mediamtx/ (gitignored). +# +# Detects OS/arch (linux + darwin; amd64/arm64/armv7/armv6) so it works on a dev Mac, an x86 server, +# and an arm64 appliance alike. Override the release with MEDIAMTX_TAG=v1.2.3. set -euo pipefail -DEST="$(cd "$(dirname "$0")/.." && pwd)/infra/mediamtx" +DEST="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/infra/mediamtx" +mkdir -p "$DEST" cd "$DEST" -TAG="$(curl -fsSL https://api.github.com/repos/bluenviron/mediamtx/releases/latest \ - | grep -oP '"tag_name":\s*"\K[^"]+')" -echo "Installing MediaMTX ${TAG} -> ${DEST}/mediamtx" + +case "$(uname -s)" in + Linux) OS=linux ;; + Darwin) OS=darwin ;; + *) echo "unsupported OS: $(uname -s) — install MediaMTX manually into $DEST" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + armv7l) ARCH=armv7 ;; + armv6l) ARCH=armv6 ;; + *) echo "unsupported arch: $(uname -m) — install MediaMTX manually into $DEST" >&2; exit 1 ;; +esac +# Upstream ships no darwin/arm-32 builds. +if [ "$OS" = darwin ] && [ "$ARCH" != amd64 ] && [ "$ARCH" != arm64 ]; then + echo "no macOS build for $ARCH upstream" >&2; exit 1 +fi + +# Resolve the latest tag with a portable parser (`grep -oP` is GNU-only and fails on macOS/BSD). +TAG="${MEDIAMTX_TAG:-$(curl -fsSL https://api.github.com/repos/bluenviron/mediamtx/releases/latest \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)}" +[ -n "$TAG" ] || { echo "could not resolve the latest MediaMTX tag (rate-limited?) — retry or set MEDIAMTX_TAG" >&2; exit 1; } + +echo "Installing MediaMTX ${TAG} (${OS}/${ARCH}) -> ${DEST}/mediamtx" curl -fsSL -o mediamtx.tar.gz \ - "https://github.com/bluenviron/mediamtx/releases/download/${TAG}/mediamtx_${TAG}_linux_amd64.tar.gz" + "https://github.com/bluenviron/mediamtx/releases/download/${TAG}/mediamtx_${TAG}_${OS}_${ARCH}.tar.gz" tar xzf mediamtx.tar.gz mediamtx rm -f mediamtx.tar.gz +chmod +x mediamtx ./mediamtx --version diff --git a/scripts/smoke_web.sh b/scripts/smoke_web.sh index fc341c0..e1ea412 100755 --- a/scripts/smoke_web.sh +++ b/scripts/smoke_web.sh @@ -2,11 +2,12 @@ # Lightweight full-stack smoke: MediaMTX + synthetic camera + core + Vite dev server. # Confirms the dashboard serves the SPA and proxies /api to the control plane. set -u -ROOT=/home/soh/cctv +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" MTX="$ROOT/infra/mediamtx/mediamtx" CORE="$ROOT/target/debug/heldar-core" -REPORT="$ROOT/data/web_smoke.txt" -LOG="$ROOT/data/web_logs"; mkdir -p "$LOG" +REPORT="$DATA/web_smoke.txt" +LOG="$DATA/web_logs"; mkdir -p "$LOG" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } @@ -21,16 +22,17 @@ cleanup(){ pkill -f 'rtsp://127.0.0.1:8554/cam_test' 2>/dev/null } trap cleanup EXIT -rm -rf "$ROOT/data/recordings/synth_cam" "$ROOT/data/heldar.db"* 2>/dev/null +rm -rf "$DATA/recordings/synth_cam" "$DATA/heldar.db"* 2>/dev/null "$MTX" "$ROOT/infra/mediamtx/mediamtx.yml" >"$LOG/mtx.log" 2>&1 & MTX_PID=$! sleep 2 +HELDAR_DEFAULT_SEGMENT_SECONDS=10 HELDAR_DATA_DIR="$DATA" "$CORE" >"$LOG/core.log" 2>&1 & CORE_PID=$! +for _ in $(seq 1 30); do curl -fsS localhost:8000/healthz >/dev/null 2>&1 && break; sleep 1; done +# After the core: MediaMTX delegates publish auth to the kernel, so publishing first gets a 401. ffmpeg -nostdin -hide_banner -loglevel warning -re -f lavfi -i "testsrc=size=1280x720:rate=15" \ -c:v libx264 -preset ultrafast -tune zerolatency -g 30 -pix_fmt yuv420p \ -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:8554/cam_test >"$LOG/synth.log" 2>&1 & SYNTH_PID=$! sleep 2 -HELDAR_DEFAULT_SEGMENT_SECONDS=10 HELDAR_DATA_DIR="$ROOT/data" "$CORE" >"$LOG/core.log" 2>&1 & CORE_PID=$! -for _ in $(seq 1 30); do curl -fsS localhost:8000/healthz >/dev/null 2>&1 && break; sleep 1; done curl -fsS -X POST localhost:8000/api/v1/cameras -H 'content-type: application/json' \ -d '{"id":"synth_cam","name":"Synthetic Test Camera","main_stream_url":"rtsp://127.0.0.1:8554/cam_test","segment_seconds":10}' >/dev/null 2>&1 diff --git a/scripts/synth_camera.sh b/scripts/synth_camera.sh index 93f4d21..530fb1f 100755 --- a/scripts/synth_camera.sh +++ b/scripts/synth_camera.sh @@ -2,6 +2,10 @@ # Publish a synthetic H.264 RTSP stream to MediaMTX so the kernel can be tested without # real cameras / credentials. Requires MediaMTX running (scripts/dev.sh or run it directly). # +# Start heldar-core FIRST: MediaMTX delegates publish authorization to the kernel +# (`authMethod: http` -> /internal/mediamtx-auth), so publishing while the core is down is +# denied with `ANNOUNCE failed: 401 Unauthorized` and ffmpeg exits straight away. +# # Usage: scripts/synth_camera.sh [path] [size] [fps] # path MediaMTX path name (default: cam_test) # size WxH (default: 1280x720) diff --git a/scripts/validate.sh b/scripts/validate.sh index 8ca93d8..913f1cf 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -3,13 +3,14 @@ # Starts MediaMTX + a synthetic camera + the core server, exercises every Stage 0 capability, # writes a report to data/validate_report.txt, and tears everything down. set -u -ROOT=/home/soh/cctv +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" MTX="$ROOT/infra/mediamtx/mediamtx" CORE="$ROOT/target/debug/heldar-core" API=http://127.0.0.1:8000 -REPORT="$ROOT/data/validate_report.txt" -LOGDIR="$ROOT/data/validate_logs" -mkdir -p "$LOGDIR" "$ROOT/data" +REPORT="$DATA/validate_report.txt" +LOGDIR="$DATA/validate_logs" +mkdir -p "$LOGDIR" "$DATA" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } @@ -29,25 +30,17 @@ cleanup(){ trap cleanup EXIT # Clean prior validation artifacts -rm -rf "$ROOT/data/recordings/synth_cam" "$ROOT/data/heldar.db"* 2>/dev/null -rm -f "$ROOT/data/clips/"*.mp4 2>/dev/null +rm -rf "$DATA/recordings/synth_cam" "$DATA/heldar.db"* 2>/dev/null +rm -f "$DATA/clips/"*.mp4 2>/dev/null hr "start MediaMTX" "$MTX" "$ROOT/infra/mediamtx/mediamtx.yml" >"$LOGDIR/mediamtx.log" 2>&1 & MTX_PID=$! sleep 2 -hr "start synthetic camera (testsrc -> rtsp://127.0.0.1:8554/cam_test)" -ffmpeg -nostdin -hide_banner -loglevel warning -re \ - -f lavfi -i "testsrc=size=1280x720:rate=15" \ - -c:v libx264 -preset ultrafast -tune zerolatency -g 30 -pix_fmt yuv420p \ - -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:8554/cam_test >"$LOGDIR/synth.log" 2>&1 & -SYNTH_PID=$! -sleep 3 - hr "start Heldar Core (segment=5s, indexer=3s)" HELDAR_DEFAULT_SEGMENT_SECONDS=5 \ -HELDAR_DATA_DIR="$ROOT/data" \ +HELDAR_DATA_DIR="$DATA" \ HELDAR_INDEXER_INTERVAL_S=3 \ HELDAR_HEALTH_INTERVAL_S=10 \ HELDAR_RETENTION_INTERVAL_S=60 \ @@ -64,6 +57,19 @@ done log "API up: $UP" [ "$UP" = 1 ] || { log "API DID NOT START — core.log tail:"; tail -n 30 "$LOGDIR/core.log" | tee -a "$REPORT"; exit 1; } +# The synthetic publisher must start AFTER the core: MediaMTX delegates publish authorization to the +# kernel (`authMethod: http` -> /internal/mediamtx-auth), so publishing before the core is up gets a +# 401 and ffmpeg exits immediately. +hr "start synthetic camera (testsrc -> rtsp://127.0.0.1:8554/cam_test)" +ffmpeg -nostdin -hide_banner -loglevel warning -re \ + -f lavfi -i "testsrc=size=1280x720:rate=15" \ + -c:v libx264 -preset ultrafast -tune zerolatency -g 30 -pix_fmt yuv420p \ + -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:8554/cam_test >"$LOGDIR/synth.log" 2>&1 & +SYNTH_PID=$! +sleep 3 +kill -0 "$SYNTH_PID" 2>/dev/null \ + || { log "SYNTHETIC CAMERA DIED — synth.log tail:"; tail -n 10 "$LOGDIR/synth.log" | tee -a "$REPORT"; exit 1; } + hr "healthz"; curl -fsS "$API/healthz"; echo | tee -a "$REPORT" hr "system (initial)"; curl -fsS "$API/api/v1/system" | tee -a "$REPORT"; echo | tee -a "$REPORT" @@ -106,9 +112,9 @@ log "recorded range: '$FROM' .. '$TO' (segment_count=$(echo "$SEGS" | python3 -c if [ -n "$FROM" ]; then hr "snapshot at $FROM (recorded)" - curl -sS "$API/api/v1/cameras/synth_cam/snapshot?at=$FROM" -o "$ROOT/data/snap_recorded.jpg" \ + curl -sS "$API/api/v1/cameras/synth_cam/snapshot?at=$FROM" -o "$DATA/snap_recorded.jpg" \ -w "http=%{http_code} bytes=%{size_download}\n" | tee -a "$REPORT" - file "$ROOT/data/snap_recorded.jpg" 2>/dev/null | tee -a "$REPORT" + file "$DATA/snap_recorded.jpg" 2>/dev/null | tee -a "$REPORT" hr "clip export $FROM .. $TO" curl -sS -X POST "$API/api/v1/cameras/synth_cam/clip" -H 'content-type: application/json' \ @@ -116,9 +122,9 @@ if [ -n "$FROM" ]; then fi hr "live snapshot (grab from stream now)" -curl -sS "$API/api/v1/cameras/synth_cam/snapshot" -o "$ROOT/data/snap_live.jpg" \ +curl -sS "$API/api/v1/cameras/synth_cam/snapshot" -o "$DATA/snap_live.jpg" \ -w "http=%{http_code} bytes=%{size_download}\n" | tee -a "$REPORT" -file "$ROOT/data/snap_live.jpg" 2>/dev/null | tee -a "$REPORT" +file "$DATA/snap_live.jpg" 2>/dev/null | tee -a "$REPORT" hr "liveview (register MediaMTX path)" curl -sS "$API/api/v1/cameras/synth_cam/liveview" -w "\n[http=%{http_code}]\n" | tee -a "$REPORT" @@ -145,7 +151,7 @@ hr "system (final)" curl -fsS "$API/api/v1/system" | tee -a "$REPORT"; echo | tee -a "$REPORT" hr "on-disk artifacts" -log "recordings/synth_cam:"; ls -la "$ROOT/data/recordings/synth_cam" 2>/dev/null | tee -a "$REPORT" -log "clips:"; ls -la "$ROOT/data/clips" 2>/dev/null | grep -E '\.mp4$' | tee -a "$REPORT" +log "recordings/synth_cam:"; ls -la "$DATA/recordings/synth_cam" 2>/dev/null | tee -a "$REPORT" +log "clips:"; ls -la "$DATA/clips" 2>/dev/null | grep -E '\.mp4$' | tee -a "$REPORT" hr "VALIDATION COMPLETE" diff --git a/scripts/validate_ai.sh b/scripts/validate_ai.sh index b27dac3..dff3a55 100755 --- a/scripts/validate_ai.sh +++ b/scripts/validate_ai.sh @@ -4,8 +4,11 @@ # and camera cam_192_168_0_2 is registered. set -u API=http://127.0.0.1:8000 -CAM=cam_192_168_0_2 -REPORT=/home/soh/cctv/data/validate_ai.txt +CAM="${CAM:-cam_192_168_0_2}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" +mkdir -p "$DATA" +REPORT="$DATA/validate_ai.txt" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } @@ -23,12 +26,12 @@ curl -s "$API/api/v1/ai/tasks" | python3 -m json.tool 2>/dev/null | tee -a "$REP log "## wait for sampler to produce a frame" FRAME_OK=0 for i in $(seq 1 25); do - code=$(curl -s -o /home/soh/cctv/data/ai_frame.jpg -w '%{http_code}' "$API/api/v1/cameras/$CAM/frame") + code=$(curl -s -o "$DATA/ai_frame.jpg" -w '%{http_code}' "$API/api/v1/cameras/$CAM/frame") if [ "$code" = "200" ]; then FRAME_OK=1; break; fi sleep 1 done log "frame http=$code ok=$FRAME_OK" -file /home/soh/cctv/data/ai_frame.jpg 2>/dev/null | tee -a "$REPORT" +file "$DATA/ai_frame.jpg" 2>/dev/null | tee -a "$REPORT" log "frame age header:" curl -s -D - -o /dev/null "$API/api/v1/cameras/$CAM/frame" | grep -i '^x-frame' | tee -a "$REPORT" diff --git a/scripts/validate_entry.sh b/scripts/validate_entry.sh index 6130196..638739c 100644 --- a/scripts/validate_entry.sh +++ b/scripts/validate_entry.sh @@ -4,8 +4,11 @@ # Runs against the stack with AUTH disabled (default); see validate_rbac.sh for the RBAC path. set -u API=http://127.0.0.1:8000/api/v1 -CAM=cam_192_168_0_2 -REPORT=/home/soh/cctv/data/validate_entry.txt +CAM="${CAM:-cam_192_168_0_2}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" +mkdir -p "$DATA" +REPORT="$DATA/validate_entry.txt" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } jqget(){ python3 -c "import sys,json;d=json.load(sys.stdin);print($1)"; } diff --git a/scripts/validate_movement.sh b/scripts/validate_movement.sh index a5c61da..ef77706 100644 --- a/scripts/validate_movement.sh +++ b/scripts/validate_movement.sh @@ -4,9 +4,12 @@ # Assumes the stack is up. Auth off (default). set -u API=http://127.0.0.1:8000/api/v1 -A=cam_192_168_0_2 -Bc=cam_movement_b -REPORT=/home/soh/cctv/data/validate_movement.txt +A="${CAM:-cam_192_168_0_2}" +Bc="${CAM_B:-cam_movement_b}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" +mkdir -p "$DATA" +REPORT="$DATA/validate_movement.txt" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } jqget(){ python3 -c "import sys,json;d=json.load(sys.stdin);print($1)"; } diff --git a/scripts/validate_rbac.sh b/scripts/validate_rbac.sh index b89aacc..fe15181 100644 --- a/scripts/validate_rbac.sh +++ b/scripts/validate_rbac.sh @@ -6,13 +6,16 @@ set -u PORT=8001 API=http://127.0.0.1:$PORT/api/v1 TMP=$(mktemp -d) -REPORT=/home/soh/cctv/data/validate_rbac.txt +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" +mkdir -p "$DATA" +REPORT="$DATA/validate_rbac.txt" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } jqget(){ python3 -c "import sys,json;d=json.load(sys.stdin);print($1)"; } code(){ curl -s -o /dev/null -w '%{http_code}' "$@"; } -cd /home/soh/cctv +cd "$ROOT" log "== starting auth-enabled core on :$PORT (temp db $TMP) ==" HELDAR_AUTH_ENABLED=true \ HELDAR_BOOTSTRAP_ADMIN_USER=admin \ diff --git a/scripts/validate_search.sh b/scripts/validate_search.sh index 4ff88d6..ec33a67 100644 --- a/scripts/validate_search.sh +++ b/scripts/validate_search.sh @@ -3,8 +3,11 @@ # proof), plan dry-run, and the identity-query audit. Assumes the stack is up. Auth off (default). set -u API=http://127.0.0.1:8000/api/v1 -CAM=cam_192_168_0_2 -REPORT=/home/soh/cctv/data/validate_search.txt +CAM="${CAM:-cam_192_168_0_2}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" +mkdir -p "$DATA" +REPORT="$DATA/validate_search.txt" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } jqget(){ python3 -c "import sys,json;d=json.load(sys.stdin);print($1)"; } @@ -48,5 +51,5 @@ log "" log "## identity-query audit (plate search writes audit_log):" curl -s -X POST "$API/search/nl" -H 'content-type: application/json' -d '{"query":"vehicle SEEK999"}' >/dev/null log " search_identity_query audit entries: $(curl -s "$API/audit?action=search_identity_query&limit=5" | jqget 'len(d)')" -log " search_log rows: $(sqlite3 /home/soh/cctv/data/heldar.db 'SELECT count(*) FROM search_log;' 2>/dev/null)" +log " search_log rows: $(sqlite3 "$DATA/heldar.db" 'SELECT count(*) FROM search_log;' 2>/dev/null)" log "DONE" diff --git a/scripts/validate_zones.sh b/scripts/validate_zones.sh index e2132dc..09eb971 100644 --- a/scripts/validate_zones.sh +++ b/scripts/validate_zones.sh @@ -3,8 +3,11 @@ # input validation. Assumes the stack is running and cam_192_168_0_2 is registered. set -u API=http://127.0.0.1:8000 -CAM=cam_192_168_0_2 -REPORT=/home/soh/cctv/data/validate_zones.txt +CAM="${CAM:-cam_192_168_0_2}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="${HELDAR_DATA_DIR:-$ROOT/data}" +mkdir -p "$DATA" +REPORT="$DATA/validate_zones.txt" : > "$REPORT" log(){ echo "$@" | tee -a "$REPORT"; } post(){ curl -s -o /dev/null -w "%{http_code} " -X POST "$API/api/v1/ai/events" -H 'content-type: application/json' -d "$1"; } diff --git a/website/docs/concepts/open-core.md b/website/docs/concepts/open-core.md index 018f37a..5aff122 100644 --- a/website/docs/concepts/open-core.md +++ b/website/docs/concepts/open-core.md @@ -46,15 +46,18 @@ The public `heldar` repository contains: ## What is proprietary -Vertical and client-specific products live as separate crates in a private -repository (`heldar-proprietary`). They **depend on** the open crates (via -crates.io, or a git tag pre-publish, with a local path patch for side-by-side -development) and layer their domain specifics on top. They are never copied into -the public repo, and the kernel never references them. +Vertical and client-specific products live as separate crates, each in its own +private repository (one per product, so their release cycles stay independent). +They **depend on** the open crates (via crates.io, with a local path patch for +side-by-side development) and layer their domain specifics on top. They are never +merged into this repo, and the kernel never references them. -The composing server isolates proprietary composition behind a seam: in the open -build that seam is a no-op stub, so the reference server links zero proprietary -code. `main.rs` is byte-identical between the open and private builds. +The composing server is a **library**: `heldar_server::run(impl Verticals)` takes +a four-hook composition trait, so a private product builds its own binary in a few +dozen lines against these crates (the kernel and apps from crates.io; the +composition crate by git tag) — no fork of this tree, no file substitution. The +`heldar-core` binary here composes the open apps and a no-op `Verticals`, so the +reference build links zero proprietary code. ## Why this shape diff --git a/website/docs/develop/module-system.md b/website/docs/develop/module-system.md index 2aae31e..6af801b 100644 --- a/website/docs/develop/module-system.md +++ b/website/docs/develop/module-system.md @@ -48,9 +48,10 @@ the shell's React and design system instead of duplicating them — the built bu (~10–50 KB) and always match the host. Why it matters: because no module UI is compiled into the dashboard, the SPA is **byte-identical for the -open and full builds**. There is one `heldar-web` image for both, and the open-repo generator drops a -proprietary vertical's UI by deleting its one self-contained directory — no per-file source patching. A -module that ships no page (e.g. a headless compute plugin) simply omits `ui_url`. +open and full builds**. There is one `heldar-web` image for both, and a proprietary vertical ships its UI +as one self-contained bundle its own repository builds and serves — nothing about it lives in, or has to +be stripped from, this tree. A module that ships no page (e.g. a headless compute plugin) simply omits +`ui_url`. ## One manifest, composed at boot + runtime diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/concepts/open-core.md b/website/i18n/es/docusaurus-plugin-content-docs/current/concepts/open-core.md index 96e39ee..adbbb2d 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/concepts/open-core.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/concepts/open-core.md @@ -52,16 +52,20 @@ El repositorio público `heldar` contiene: ## Qué es propietario Los productos verticales y específicos para clientes residen como crates -separados en un repositorio privado (`heldar-proprietary`). **Dependen de** los -crates abiertos (a través de crates.io, o una etiqueta git previa a la -publicación, con un parche de ruta local para el desarrollo en paralelo) y -añaden sus especificidades de dominio encima. Nunca se copian en el repositorio -público y el kernel nunca los referencia. +separados, cada uno en su propio repositorio privado (uno por producto, para que +sus ciclos de publicación sean independientes). **Dependen de** los crates +abiertos (a través de crates.io, con un parche de ruta local para el desarrollo +en paralelo) y añaden sus especificidades de dominio encima. Nunca se fusionan en +este repositorio y el kernel nunca los referencia. -El servidor de composición aísla la composición propietaria detrás de una -interfaz: en la compilación abierta esa interfaz es un stub sin operación, por -lo que el servidor de referencia no enlaza ningún código propietario. `main.rs` -es byte a byte idéntico entre las compilaciones abierta y privada. +El servidor de composición es una **biblioteca**: `heldar_server::run(impl +Verticals)` recibe un trait de composición de cuatro puntos de enganche, así que +un producto privado construye su propio binario en unas pocas docenas de líneas +frente a estos crates (el kernel y las apps desde crates.io; el crate de +composición por etiqueta git) — sin bifurcar este árbol ni sustituir archivos. El +binario `heldar-core` de aquí compone las apps abiertas y un `Verticals` sin +operación, por lo que la compilación de referencia no enlaza ningún código +propietario. ## Por qué esta estructura diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/open-core.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/open-core.md index a283369..fc5edd8 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/open-core.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/open-core.md @@ -25,9 +25,9 @@ Heldar 以开放核心平台的形式发布:Apache-2.0 内核和一组通用 ## 私有部分 -垂直行业和客户专属产品以独立 crate 的形式存放在私有仓库(`heldar-proprietary`)中。它们**依赖于**开放 crate(通过 crates.io,或发布前的 git 标签,以及用于并行开发的本地路径补丁),并在其之上叠加各自的领域细节。它们不会被复制到公开仓库,内核也不会引用它们。 +垂直行业和客户专属产品以独立 crate 的形式存放在各自的私有仓库中(每个产品一个仓库,使其发布周期互不影响)。它们**依赖于**开放 crate(通过 crates.io,以及用于并行开发的本地路径补丁),并在其之上叠加各自的领域细节。它们不会被合并进本仓库,内核也不会引用它们。 -组合服务器通过接缝将私有组合逻辑隔离:在开放构建中,该接缝是一个无操作的存根,因此参考服务器不链接任何私有代码。`main.rs` 在开放构建和私有构建之间字节完全相同。 +组合服务器本身是一个**库**:`heldar_server::run(impl Verticals)` 接受一个包含四个钩子的组合 trait,因此私有产品只需针对这些 crate(内核与应用来自 crates.io,组合 crate 通过 git 标签引用)编写几十行代码即可构建自己的二进制文件——无需 fork 本仓库,也无需替换文件。本仓库的 `heldar-core` 二进制文件组合了开放应用和一个无操作的 `Verticals`,因此参考构建不链接任何私有代码。 ## 为何采用此架构 From c53cf0e913289e0eef80cb540e198145d1ad9393 Mon Sep 17 00:00:00 2001 From: wms2537 <51080539+wms2537@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:20:03 +0800 Subject: [PATCH 2/2] docs(adr): publish the platform decision records Shipped code and docs cited ADR 0001/0003/0004 as the design records of the open platform, but docs/adr/ did not exist here -- contributors were pointed at documents they could not read. Publishes the two that describe THIS codebase: - 0003: remote viewing over WebRTC; retiring the mobile app + kernel-managed WireGuard. Trimmed before publishing: the phase-by-phase operational log (infrastructure secret names, deployment TODOs) and the list of deliberately deferred security hardenings on the internet-facing relay -- an attacker roadmap that does not belong in public. The decision, rationale, alternatives, consequences and risks are intact. - 0004: edge nodes stay on SQLite; scale by adding nodes rather than porting to Postgres. Cross-references rewritten: it cited 'issue #36' (a dependabot PR in this repo), CLAUDE.md (a private file), and ADR 0001 (unpublished). Decisions wholly about the commercial tier stay private; docs/adr/README.md says so rather than leaving the gap unexplained. Also: services/embeddings.rs credited the no-vector-DB/no-ANN choice to ADR 0004, which is the SQLite-vs-Postgres brief -- no ADR records that decision, so the rationale now stands on its own. CONTRIBUTING notes that 'issue #NN' in comments predating 2026-07 refers to the pre-flip tracker, not this repo (65 such references, where e.g. #38 is now a setup-node bump and #46 is a live good-first-issue). Signed-off-by: wms2537 <51080539+wms2537@users.noreply.github.com> --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 11 +- CONTRIBUTING.md | 6 + .../heldar-kernel/src/services/embeddings.rs | 2 +- docs/REMOTE-ACCESS.md | 10 +- docs/adr/0003-webrtc-remote-access.md | 144 ++++++++++++++++++ docs/adr/0004-store-abstraction-postgres.md | 126 +++++++++++++++ docs/adr/README.md | 19 +++ 8 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0003-webrtc-remote-access.md create mode 100644 docs/adr/0004-store-abstraction-postgres.md create mode 100644 docs/adr/README.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d892edd..ab41d2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2023,7 +2023,7 @@ useless), so the only thing that reaches it is the node **dialing out**. Remote **browser-based WebRTC**: the box dials OUT to a **signaling + TURN service** in the control plane, NAT traversal is negotiated over ICE, and a browser viewer plays live video over **WHEP** (served by MediaMTX). The deploy recipes live in `docs/REMOTE-ACCESS.md`; this section is the architecture of -record (originally decided as ADR 0003). +record, and the decision that produced it is `docs/adr/0003-webrtc-remote-access.md`. **Two layers, kept separate.** *Reachability* is WebRTC: the box and the browser exchange SDP/ICE through the control-plane **signaling** endpoint, then establish a direct peer connection whenever diff --git a/CHANGELOG.md b/CHANGELOG.md index facf17a..c6228b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 CONTRIBUTING and would have told a contributor their PR gets regenerated away. They now describe the real model: this repo is the source of truth, and a private product composes its own binary against `heldar_server::run(impl Verticals)`. -- `ARCHITECTURE.md` §21 no longer points at a `docs/adr/` path that does not exist in this repo. +- **Architecture decision records are published** under [`docs/adr/`](docs/adr): ADR 0003 (remote + viewing over WebRTC; retiring the mobile app and kernel-managed WireGuard) and ADR 0004 (edge nodes + stay on SQLite rather than porting to Postgres). Shipped code and docs cited these as the design + records while the directory did not exist here, so `ARCHITECTURE.md` §21 and `REMOTE-ACCESS.md` now + link to a document a reader can actually open. Decisions that are wholly about the commercial tier + stay unpublished, and the index says so. +- `services/embeddings.rs` credited the no-vector-DB/no-ANN choice to ADR 0004, which is the + SQLite-versus-Postgres brief; no ADR records that decision, so the rationale now stands on its own. +- CONTRIBUTING notes that `issue #NN` references predating 2026-07 point at the tracker used before + development moved into the open, and do not match this repo's issue numbers. - README/CONTRIBUTING dev setup now includes the dashboard `npm ci` step that `run_stack.sh` requires, and the README no longer describes the production overlay as switching to a private image (it is an open hardening overlay). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f658408..f2654b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,12 @@ cargo build -p heldar-server --features smtp --locked cd apps/web && npm ci && npm run build ``` +- **Design decisions are recorded.** [`docs/adr/`](./docs/adr) holds the decision records — why the + system is the way it is, as opposed to how it works today (that's [ARCHITECTURE.md](./ARCHITECTURE.md)). + A decision that changes a seam or reverses one of these deserves a new ADR alongside the code. +- **A note on `issue #NN` in comments.** References predating 2026-07 point at the tracker this project + used before it moved development into the open; they do **not** correspond to issue numbers in this + repo. They are kept as provenance for the original design discussion — don't follow them here. - **Architecture seams matter.** Apps plug into the kernel only through public seams (the `DetectionConsumer` trait, `Router` merging, a self-installed schema, the auth primitive). Don't add app-specific knowledge to the kernel — see [ARCHITECTURE.md](./ARCHITECTURE.md). diff --git a/crates/heldar-kernel/src/services/embeddings.rs b/crates/heldar-kernel/src/services/embeddings.rs index 0bee392..9586efc 100644 --- a/crates/heldar-kernel/src/services/embeddings.rs +++ b/crates/heldar-kernel/src/services/embeddings.rs @@ -2,7 +2,7 @@ //! worker's `embedding` task, the pull-only query-embedding job queue, and brute-force cosine //! top-k over the stored vectors. //! -//! Vectors live as little-endian f32 BLOBs in SQLite (no vector DB / ANN index — ADR 0004): at +//! Vectors live as little-endian f32 BLOBs in SQLite (deliberately no vector DB / ANN index): at //! single-box scale even a million 512-d vectors scan in tens of milliseconds, and the scan //! streams rows so peak memory stays at one row + the k-sized heap. //! diff --git a/docs/REMOTE-ACCESS.md b/docs/REMOTE-ACCESS.md index d1ab0e1..0b25951 100644 --- a/docs/REMOTE-ACCESS.md +++ b/docs/REMOTE-ACCESS.md @@ -6,9 +6,9 @@ useless). This is an **open kernel** capability: every deployment of the Apache- private remote viewing out of the box. Remote access is **WebRTC-primary, browser-based** — the design of record is -[`ARCHITECTURE.md`](../ARCHITECTURE.md) §21, which carries the full WebRTC model (originally decided -as ADR 0003). All phases are **shipped** (P1 live video → P2 universal reach → P3 the full dashboard; see -_Status & phasing_ below). The optional self-hoster **overlay** paths (Recipes A/B) remain available +[`ARCHITECTURE.md`](../ARCHITECTURE.md) §21, which carries the full WebRTC model; the decision that +produced it is [ADR 0003](adr/0003-webrtc-remote-access.md). All phases are **shipped** (P1 live +video → P2 universal reach → P3 the full dashboard; see _Status & phasing_ below). The optional self-hoster **overlay** paths (Recipes A/B) remain available for operators who prefer full-L3 reach over the hosted rendezvous. Hardening a deployment for the public internet: [`docs/PRODUCTION.md`](PRODUCTION.md). @@ -21,7 +21,7 @@ public internet: [`docs/PRODUCTION.md`](PRODUCTION.md). **signaling + TURN hosted in `heldar-control-plane`**; live video rides **MediaMTX / WHEP** (`:8889`). Media is **end-to-end encrypted (DTLS-SRTP)**: the rendezvous brokers only SDP/ICE and relayed control, never the video bytes. Design: [`ARCHITECTURE.md`](../ARCHITECTURE.md) §21 - (ADR 0003). + ([ADR 0003](adr/0003-webrtc-remote-access.md)). - **Optional (works today): a WireGuard overlay** running as an external daemon on the host, for self-hosters who want full L3 reachability rather than just the browser view. - **Your own / dev use → Tailscale** (Personal, free): zero servers, near-zero ops, $0. @@ -85,7 +85,7 @@ Headscale) removes even that third-party metadata, at the cost of a small VPS to ## Status & phasing WebRTC remote access **shipped in three phases, all landed** (implementation: -[`ARCHITECTURE.md`](../ARCHITECTURE.md) §21; full phased plan: ADR 0003): +[`ARCHITECTURE.md`](../ARCHITECTURE.md) §21; the decision: [ADR 0003](adr/0003-webrtc-remote-access.md)): - **P1 — LAN / WHEP ✅:** sub-second live video in the browser over MediaMTX WHEP (`:8889`) on the LAN. - **P2 — universal reach ✅:** the box dials out to **signaling + TURN**, so the same browser view works diff --git a/docs/adr/0003-webrtc-remote-access.md b/docs/adr/0003-webrtc-remote-access.md new file mode 100644 index 0000000..92a9666 --- /dev/null +++ b/docs/adr/0003-webrtc-remote-access.md @@ -0,0 +1,144 @@ +# ADR 0003 — Remote camera viewing over WebRTC; retire the mobile app + kernel WireGuard + +**Status:** accepted (2026-06-21). Supersedes ADR 0002 (a React Native app embedding a +kernel-managed WireGuard tunnel), which is not published here. +**Context date:** 2026-06-21. Touches the kernel (`crates/heldar-kernel`), the dashboard +(`apps/web`), and the private fleet control-plane. + +> The architecture that resulted is documented in [`ARCHITECTURE.md`](../../ARCHITECTURE.md) §21 and +> operationally in [`docs/REMOTE-ACCESS.md`](../REMOTE-ACCESS.md). This ADR records only why the +> earlier approach was abandoned and this one chosen. + +## Context + +ADR 0002 chose a React Native app that embeds a **kernel-managed WireGuard** tunnel, betting on the +box's **public IPv6** for reachability. In practice that bet fails the product bar — *"a customer can +view their cameras from any device, on any network, with the least friction."* + +What we hit: + +- **Plain WireGuard does no NAT traversal.** It is an encrypted point-to-point transport; for a + handshake to start, the client must reach the box's endpoint. There is no hole-punching, no relay, + no rendezvous — that coordination layer is what Tailscale/NetBird add *on top* of WireGuard. The + kernel shipped *plain* managed WireGuard and relied on IPv6 for reachability. +- **IPv6 is not universal.** The box is frequently behind IPv4 **CGNAT** (only outbound works). Its + public IPv6 is reachable only from clients that *also* have working IPv6 — which excludes IPv4-only + networks and, notably, the **Android emulator** (its user-mode network is IPv4-only). We reproduced + exactly this: a healthy box (WireGuard listening, cameras configured) that no client on hand could + reach. +- **A native app is friction.** The app forced two Apple/Google **organization** developer accounts + for the VPN entitlements (App Store Guideline 5.4; Play `VpnService` disclosure) before we could + even ship — a hard gate. + +The real requirement is **universal reachability (NAT traversal)** plus **no app**. Both are +properties of a *reachability layer*, not of the tunnel technology. The universal pattern — for any +transport — is **the box dials OUT to a public rendezvous** (outbound traverses CGNAT/firewalls), and +the client meets it there. + +## Decision + +Pivot remote access to **WebRTC**: + +1. **Media over WebRTC/WHEP.** Live camera video is delivered via WebRTC, reusing the **MediaMTX + WHEP** endpoint the box already serves (`HELDAR_MEDIAMTX_WEBRTC_BASE`, `:8889`; the kernel mints + `LiveUrls.webrtc_url` in `services/mediamtx.rs`). NAT traversal is ICE/STUN with **TURN** relay + fallback. TURN is operator-tunable: `HELDAR_WEBRTC_ICE_SERVERS` (a MediaMTX + `webrtcICEServers2`-shaped JSON array) lets an operator bring their own STUN/TURN; unset, the box + uses short-lived credentials minted by the rendezvous, else STUN/LAN-only. HLS stays as the + always-works fallback transport. +2. **Browser-native, no app.** The viewer is the existing **`apps/web`** dashboard. No native app, no + app-store VPN entitlements. +3. **Box dials OUT to a rendezvous.** An opt-in kernel service maintains an **outbound** connection to + a public signaling service, modeled on the existing `services/fleet_register.rs` (dials out, parks + when unconfigured, no inbound port). This is what makes CGNAT boxes reachable. +4. **Signaling + TURN live in the commercial tier.** They need an always-on public endpoint and + per-customer operational ownership, so they sit with the fleet control-plane rather than in the + kernel. A deployment with no rendezvous configured degrades to LAN/WHEP-only. +5. **Pairing model reused.** The single-use, short-TTL, manager-minted **pairing token** concept is + repurposed to authorize a browser WebRTC session and bind it to a rendezvous channel + TURN lease — + keeping the manager-minted / audited / short-TTL properties. + +And remove the superseded pieces: + +6. **Delete the React Native app** and its local WireGuard module. +7. **Remove the kernel-managed `wireguard` feature entirely** — `services/wireguard.rs`, + `routes/remote_access.rs`, the `wireguard` cargo feature, `HELDAR_WG_*` config, the boot bring-up, + and the `CAP_NET_ADMIN`/`setcap` deployment plumbing. This also deletes an **unauthenticated + token-gated `/pair`** endpoint and the privileged `ip`/`wg` shell-out surface — a net security and + ops simplification. + +**Kept:** the separate, always-on **external-overlay *awareness*** (`services/remote_access.rs`, +`HELDAR_OVERLAY_*`, `OverlayStatus` via `/api/v1/system`). It only *observes* an externally-run overlay +(Tailscale/NetBird/wg) and is the supported **self-hoster full-L3 path**: a self-hoster who wants +transparent whole-box/LAN access runs their own overlay and the kernel reports its health. We no longer +ship a kernel-managed tunnel. + +## Consequences + +**Pros** +- Works on any device and any network (ICE + TURN) — the requirement plain WireGuard missed. +- No app, and no app-store organization-account gating. The whole dashboard becomes the remote surface. +- Reuses what we already run: MediaMTX/WHEP, the dial-out pattern, the token model. +- Smaller, safer kernel: no privileged networking, no `CAP_NET_ADMIN`, no unauthenticated `/pair`. + +**Cons / costs** +- Adds a **cloud dependency** for the universal path (signaling + TURN). Mitigated: the LAN/WHEP path + needs no cloud, and the self-hoster overlay path needs no Heldar cloud at all. +- WebRTC gives media plus a control channel, not transparent L3. The **full dashboard** remotely + (playback, config, API) therefore needs a relayed-control channel, which WireGuard gave for free. + Self-hosters who need true L3 use the overlay path. +- TURN relay consumes bandwidth for the symmetric-NAT tail; ICE prefers direct/STUN first. + +## Alternatives considered + +- **Keep WireGuard, add a coordination/relay layer (Headscale/Tailscale-style).** Keeps transparent L3 + and would solve reachability, but keeps the native-app requirement and a privileged data plane, and + is more infrastructure to build and run than reusing WebRTC. Rejected as the *primary* path; + self-hosters can still get L3 via the kept overlay-awareness. +- **Hybrid (WebRTC for most users, kernel-managed WireGuard for power users).** Rejected: the + kernel-managed WireGuard carried real cost (privileged paths, the unauthenticated `/pair`, + deployment capability plumbing) for a narrow audience already served by *external* overlays plus + overlay-awareness. + +## Delivery + +Built in phases, each independently demoable, all since shipped: LAN WHEP live video (no cloud) → +outbound rendezvous + TURN for universal reach → the full dashboard over a relayed-HTTPS transport → +cutover (mobile app deleted, `wireguard` feature removed, docs rewritten). + +The relayed control channel is **two-gate**: an outer per-user site capability gates *reachability*, +and the browser's real kernel session is replayed against the box's own loopback kernel, which runs its +**normal RBAC**. The kernel remains the sole auth authority — the relay never injects a principal — and +the box refuses to relay at all unless kernel auth is on and a real user exists. That model was +adversarially reviewed before implementation; see [`docs/PRODUCTION.md`](../PRODUCTION.md) for the +posture an internet-exposed deployment is expected to run. + +## Open-core implications + +The open kernel keeps a complete remote-access story; the universal-reach coordination is commercial: + +- **Open (Apache-2.0, this repo):** kernel WHEP minting (`services/mediamtx.rs`); the browser + WebRTC/WHEP viewer in `apps/web`; the box-side outbound rendezvous **client** (opt-in, parks when + unconfigured — the same pattern as the already-open `services/fleet_register.rs`); external-overlay + awareness. +- **Commercial:** the signaling/rendezvous service and TURN coordination. This mirrors the fleet split + — the open kernel can *dial* a coordinator; running one is the proprietary tier. + +The open dashboard and the `--no-default-features` build must degrade gracefully to LAN/WHEP-only when +no rendezvous is configured, since this repo ships no signaling server. + +## Risks + +- **TURN bandwidth/capacity** for the symmetric-CGNAT tail. Mitigate: ICE prefers direct/STUN; monitor + the relay share. +- **ICE candidate gaps** — MediaMTX must advertise reachable candidates or WHEP silently fails behind + NAT. Mitigate: configure `webrtcAdditionalHosts`/ICE servers plus STUN; keep the HLS fallback. +- **Cloud dependency** for the universal path. Mitigate: the LAN path and the self-hoster overlay path + need no Heldar cloud. +- **Auth on the relayed control channel** touches a high-risk, historically low-test surface + (`routes/auth.rs`). Mitigate: characterization tests first; keep tokens single-use, short-TTL, + manager-minted and audited. +- **Privacy** — the rendezvous must never see plaintext media. Mitigate: media stays on WebRTC/TURN + (DTLS-SRTP); the rendezvous brokers only SDP/ICE and the relayed control API. +- **Sunk cost** — retiring the mobile app (Android verified, iOS authored). Mitigate: its pairing/QR UX + informed `apps/web`; nothing shipped to stores; the rationale is recorded here. diff --git a/docs/adr/0004-store-abstraction-postgres.md b/docs/adr/0004-store-abstraction-postgres.md new file mode 100644 index 0000000..dce6528 --- /dev/null +++ b/docs/adr/0004-store-abstraction-postgres.md @@ -0,0 +1,126 @@ +# ADR 0004 — Store abstraction & Postgres-per-node (decision brief) + +**Status:** ACCEPTED (no port) — owner decision recorded 2026-07-16. Edge nodes stay on +SQLite; horizontal scale = more edge nodes fanning into the control-plane Postgres. Reopen only on +measured WAL-writer saturation on a real box. +**Date:** 2026-07-12 +**Context:** an earlier decision floated running Postgres on a busy node behind a `Store` abstraction; it was never built, and 56 files +bind `SqlitePool`. This brief costs the port honestly so the Postgres investment can be decided against measured +demand rather than assumed. + +## The real question + +Not *"how do we port the edge kernel to Postgres"* but *"does an individual edge node actually need +Postgres-grade write concurrency yet?"* Scaling was already split into two planes: + +- **Control plane** — must absorb many nodes' concurrent writes. **Already Postgres** (the commercial fleet + control-plane is native `PgPool`, `$1/$2`, `EXCLUDED` upserts, `timestamptz`/`jsonb`, e2e-verified on PG16). This is the part + that genuinely needs Postgres concurrency, and it ships today. +- **Edge node** — owns its cameras + a local store. Horizontal scale = **add more edge nodes**, each keeping local + SQLite, fanning into the shared Postgres index via the append-only outbox. **Zero port required.** + +Per-node Postgres only buys anything when a **single box** must vertically absorb ~200 cameras **and provably +saturates the serial WAL writer**. But the measured per-node ceiling (~8 full-fps AI cameras) is set by the +**in-process decode/FPS budget** (`services/sampler.rs`), **not the DB writer**. Today Postgres would raise a +ceiling nothing is hitting. + +## The measured port cost (why it's not cheap) + +- **`?`→`$N` is manual.** sqlx-postgres accepts only `$1,$2`; there is no runtime auto-translation. ~338 runtime + queries / ~1175 `.bind()` calls. sqlx `Any` is **disqualified** — `AnyRow/AnyValue` decode only primitives and + can't handle `DateTime`, `Json`, or the 63 `#[derive(FromRow)]` structs. `QueryBuilder` is *more* + churn. So it's a hand-rewrite, per-query review. +- **No compiler help.** 0 compile-time `query!`/`query_as!` macros → every wrong placeholder/type is a **runtime** + error, caught only by tests — and there is **no edge Postgres test matrix today**. +- **`u32/usize` bind audit** — PG sqlx has no unsigned types (e.g. `db_maintenance.rs prune batch: u32`). +- **A whole SQLite-only subsystem has no PG analog:** `db.rs` PRAGMAs (WAL/busy_timeout/auto_vacuum), the entire + `services/db_maintenance.rs` size-cap (`incremental_vacuum`, `VACUUM`, `wal_checkpoint`), `VACUUM INTO` backup, + and the **single-writer concurrency contract** the `SegReadLock` model + retention TOCTOU guard + + `SQLITE_BUSY→503` mapping rely on. The self-bounding-storage principle would be **re-implemented** on PG + (`pg_total_relation_size` + partition/TRUNCATE), not translated. +- **Permanent dual-maintenance tax** — every future query + migration authored and tested twice, forever, and + that cost ossifies daily. + +**Realistic effort: ~6–12 weeks of senior work**, dominated by the Store plumbing across 54 files + a from-scratch +PG CI matrix. **De-riskers:** the control-plane already runs the exact dual-dialect sqlx pattern on PG16, and a Docker +spike proved the data model round-trips — so the port is a **known quantity**, startable on short +notice, which is exactly why *deferring it costs little*. + +## Dialect diffs (small, already inventoried) + +`ON CONFLICT ... excluded` ↔ `EXCLUDED` port verbatim (identifiers are case-insensitive in both; control-plane +already runs the uppercase form). 0 `INSERT OR REPLACE/IGNORE`. 0 SQL date/JSON funcs (the `strftime` hits are +ffmpeg args, not SQL). `AUTOINCREMENT`→`generated by default as identity`; DDL `DEFAULT 1`→`DEFAULT true`; +TEXT-RFC3339→`timestamptz`, `Json`→`jsonb` — the Rust bind/decode code is unchanged given the right PG schema. + +## Options + +| Option | What | Cost | Right when | +|---|---|---|---| +| **A — Horizontal via control-plane fan-in** (status quo) | Add edge nodes, each local SQLite, drain outbox into the central PG index | ~zero (already built + PG16-verified) | The dominant case, until a single node is provably write-bound | +| **B — Squeeze the write path + Store seam as insurance** | Isolate the hot ingest path (detections/outbox/fanout) behind its own pool/DB so analytics can't starve recording; adopt the Store-seam discipline incrementally | Tens of call sites; real resilience on today's SQLite, no PG | You want to relieve contention + pre-position reversibly, cheaply | +| **C — Full per-node Postgres** (dual-backend Store) | Store trait over `SqlitePool`/`PgPool`, ~338 queries `?`→`$N`, parallel PG migrations, re-impl maintenance | HIGH, ~6–12 wks, runtime-only failure surface, permanent dual tax | A single box must hit ~200 cameras AND is *measured* saturating the WAL writer AND the write-split was spent + proven insufficient | +| **D — Hybrid (3 hot tables to PG)** | Move only detections/outbox/fanout to PG | **Worst of both** — breaks the atomic single-tx ingest (double-counted ANPR votes); a new consistency bug | Essentially never — **rejected** | + +## Recommendation (owner's call) + +**Do not fund full per-node Postgres (C) now; reject the hybrid (D) outright.** Take **A** (already delivered) as +the scaling answer, and spend on **B**: +1. Build the **media-plane/ingest write-split** so analytics ingest can never starve recording — real value on + today's SQLite, no PG. +2. Fund **out-of-process decode first** — the measured wall is decode-bound, not DB-bound. Postgres would + raise a ceiling nothing is hitting. +3. Adopt the **Store-seam discipline incrementally** (isolate new `?`/`excluded`/PRAGMA call-sites behind a seam as + they land) as cheap reversibility insurance — but **do not stand up a `PgPool` speculatively.** + +**Trigger the full port (C) only reactively:** when a specific customer's single box is provably saturating the +serial WAL writer (not the decoder) and the write-split has been spent and proven insufficient. The port is a known quantity, +so deferring it is low-risk. + +## Store abstraction — shape & hard limit (if pursued) + +- **Per-aggregate repositories**, NOT one ~300-method god trait: `OutboxRepo`, `DetectionRepo`, `SegmentRepo`, + `CameraStatusRepo`, `AuthRepo`, `EventRepo`, `RetentionRepo`. Seams already exist (`repo.rs`, `routes/ai.rs`, + the `DetectionConsumer` in `state.rs`). Each repo takes a backend-generic executor; tests fake/mem-SQLite one + repo at a time. +- **What it buys:** a clean backend swap-point + reversibility discipline (SQLite-flavored call-sites live behind + ONE boundary instead of smeared across 54 files, so they stop multiplying). +- **HARD LIMIT (state plainly):** a trait with SQLite SQL *inside its impl* is **not** Postgres-portable — the + dialect lives in the query STRINGS, below the trait boundary. **The trait organizes the port; it does not + deliver it.** The `?`→`$N` rewrite, the maintenance re-implementation, and the `u32` audit are a separate, + larger cost the trait merely houses. The "build it for testability even if PG never ships" argument is **weak + here** — the suite already uses fast hermetic in-memory SQLite with real SQL semantics, so that upside is banked. + +## Phased plan (only if Postgres is chosen) + +0. **Gate first, code second.** Instrument a real busy node; confirm the bottleneck is the WAL writer, not decode + (`sampler.rs`). If decode → do the out-of-process decode work and **STOP**. The whole cost/payoff hinges on this gate. +1. **Store seam on SQLite only** (green throughout): per-aggregate repo traits, move ~130 `&SqlitePool` signatures + behind them, land the SqlitePool impl. Start with the hot ingest path so it also delivers the write-split. +2. **Parallel PG migrations, no runtime path.** Per-backend migration sets using the ADR mapping table; Dockerized + PG16 in CI applies them + round-trips the hard patterns (partial-unique `ON CONFLICT DO NOTHING`, composite-PK + fanout claim, retention TOCTOU DELETE). SQLite stays default + green. +3. **PgPool impl behind an off-by-default cargo feature** (mirror `smtp`/`wasm`). Rewrite queries inside the PG + impls (`?`→`$N`, `excluded`→`EXCLUDED`, `$N`-indexed builders, `u32`→`i64` audit). A PG integration test per + repo IS the safety net (no compile-time macros). Re-implement the maintenance seam for PG. +4. **Dual-run verification gate** on a real node: same ingest/retention/auth workload against BOTH backends, diff + outputs (row counts, fan-out claims, retention deletions, ANPR idempotency) — catches a silent `?`→`$N`/type + mismatch the compiler can't. Only after a clean dual-run does a busy node select `PgPool`; small nodes stay + SQLite. **Never remove the SQLite path** (single nodes still want its self-bounding storage). +5. **Ongoing:** every new migration + query written and tested against both backends. Budget this permanent tax + explicitly. + +## Key risks + +- **Wrong-wall (highest):** the ceiling is decode-bound (`sampler.rs` ~8 AI cameras), not DB-bound; PG raises a + ceiling nothing hits. Out-of-process decode is the real lever. +- **Runtime-only failures:** 0 compile-time macros → the compiler flags none of the ~338 placeholder rewrites; + defects land on the auth/recorder/retention paths that are historically high-risk and low-test. +- **Ossification:** permanent dual-dialect tax that compounds as verticals land. +- **SQLite-only subsystem loss:** the size-cap/backup/concurrency machinery is re-implemented, not translated — a + design-principle-level change. +- **Concurrency-contract shift:** WAL single-writer underpins `SegReadLock`, the retention TOCTOU guard, and the + `SQLITE_BUSY→503` mapping; PG's MVCC changes the contract — lost-update assumptions need re-review. +- **Sequencing:** the tax grows with every `?`-positional query added, so deferring the *seam discipline* + indefinitely means a larger, more urgent port if saturation ever arrives. Mitigate by adopting the seam + incrementally now while deferring the actual `PgPool`. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..1e9f77f --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,19 @@ +# Architecture Decision Records + +An ADR records a decision that shaped the platform: the context that forced it, the options weighed, +what was chosen, and what it cost. They are written once and then left alone — a superseded ADR is +marked superseded rather than edited, so the reasoning stays readable years later. + +These are **history, not documentation**. For how the system works today, read +[`ARCHITECTURE.md`](../../ARCHITECTURE.md); an ADR tells you *why* it is that way. + +| ADR | Decision | Status | +|---|---|---| +| [0003](0003-webrtc-remote-access.md) | Remote camera viewing over WebRTC; retire the mobile app and kernel-managed WireGuard | Accepted (2026-06-21) | +| [0004](0004-store-abstraction-postgres.md) | Edge nodes stay on SQLite; scale horizontally rather than porting to Postgres | Accepted — no port (2026-07-16) | + +Numbers are not contiguous here. Decisions that are wholly about the commercial tier — fleet +control-plane topology, repository and licensing strategy — are recorded in a private tracker, since +they concern infrastructure and business arrangements rather than this codebase. Where such a decision +constrains the open platform, that constraint is stated in the open ADRs and in `ARCHITECTURE.md` +rather than left implicit.