From a001a09fdf551a2357e96501fae61337949148fe Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Tue, 8 Sep 2026 16:38:10 -0400 Subject: [PATCH] Add Dockerfile and deployment for replacing news-backend in place Two-stage build. The builder bakes the embedding model into the image (~65 MB), so a cold container needs no outbound access to Hugging Face and starts scoring in 0.4s -- verified with HF_HUB_OFFLINE=1. Three things the smoke test caught that would each have failed in production: - Debian's base image already ships a system account named "news", so useradd exits 9 on the collision. The runtime user is "app". - The image runs as uid 10001 rather than root, so a host-owned volume gives SQLite "attempt to write a readonly database". Compose needs the `U` mount flag. - The baked model cache was root-owned, making fastembed log a permission warning and discard its tree cache on every start. Deployment targets the EXISTING service rather than a parallel one: same vhost, and NTFY_TOPIC=news-triage because the old service defaulted to that rather than setting it, so the phone subscription keeps working. The Caddy change is the consequential one and is documented as such: the vhost moves from LAN-only to public, with /api/feedback/* bypassing Authelia because ntfy runs action buttons from the phone and a native app cannot complete an interactive login -- the same exemption ntfy, Immich and Jellyfin already have on this box. That path is HMAC-signed per (cluster, kind). --- deploy/README.md | 124 ++++++++++++++++++++++++++++++----------------- news/config.py | 6 ++- news/embed.py | 12 +++-- 3 files changed, 93 insertions(+), 49 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 1c1e874..45d05a6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,86 +1,120 @@ # Deploying -Three things must be true before the system is useful, and the third is the one -the previous version got wrong. +This replaces the `news-backend` service (from `../news_triage`) in place: same +host, same vhost, same ntfy topic. -## 1. Compose service +## 1. Compose + +Replace the `news-backend` block in `~/compose.yml`: ```yaml - news: + news-backend: image: git.rcjohnstone.com/connor/news:latest restart: unless-stopped - networks: [internal] + networks: + - internal environment: - TZ=America/Louisville - NEWS_DB=/data/news.db - NEWS_CONFIG_DIR=/config - # ntfy. The topic must differ from news_triage's during the parallel run. - NTFY_URL=${NTFY_URL} - - NTFY_TOPIC=news2 + # The old service defaulted to this topic (bin_support.rs:121) rather than + # setting it, so reusing it keeps the existing phone subscription working. + - NTFY_TOPIC=news-triage - NTFY_USER=${NTFY_USER} - NTFY_PASS=${NTFY_PASS} - # REQUIRED for feedback buttons -- see section 3. - - NEWS_API_BASE_URL=https://news2.rcjohnstone.com + # REQUIRED for feedback buttons. ntfy runs http actions FROM THE PHONE, so + # this must be the public URL, not a container address. Its absence is + # exactly why the previous version's buttons never fired. + - NEWS_API_BASE_URL=https://news.rcjohnstone.com - NEWS_FEEDBACK_SECRET=${NEWS_FEEDBACK_SECRET} # One LLM call per day, for the digest prose only. - NEWS_LITELLM_KEY=${NEWS_LITELLM_KEY} volumes: - - ./data/news2:/data + # `U` makes podman chown the volume to the container's uid. The image runs + # as uid 10001, not root, so without it SQLite fails with + # "attempt to write a readonly database". + - ./data/news2:/data:U - ./config/news2:/config:ro healthcheck: - # NOT curl. The v0.2 image shipped no curl, so its healthcheck failed - # forever while the service was fine and `podman ps` lied for four days. + # NOT curl: the v0.2 image shipped none, so its healthcheck failed forever + # while the service was fine and `podman ps` reported it unhealthy for days. test: ["CMD", "python", "-c", - "import urllib.request;urllib.request.urlopen('http://localhost:3000/healthz')"] + "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:3000/healthz',timeout=4).status==200 else 1)"] interval: 60s timeout: 5s retries: 3 start_period: 20s ``` -Remember that a bind-mounted config change needs `sudo podman restart news`; -`podman-compose up -d` will not restart a container when only a mounted file -changed. An **environment** change does need the full `up -d`. +Copy `profile/*.toml` to `~/config/news2/`. A bind-mounted config edit needs +`sudo podman restart news-backend`; `podman-compose up -d` will not restart a +container when only a mounted file changed. An **environment** change does need +the full `up -d`. -## 2. Caddy vhost +## 2. Caddy + +The existing `news.rcjohnstone.com` block serves a static SPA from +`/srv/news/dist` and proxies only `/api*`. This app renders its own pages, so +the block simplifies -- but it must also become reachable from the phone. ``` -news2.rcjohnstone.com { - reverse_proxy news:3000 +# ddns: public +news.rcjohnstone.com { + import secure_headers + import accesslog + + # ntfy executes a notification's action buttons FROM THE PHONE, and a native + # app cannot complete an interactive login -- the same reason ntfy, Immich + # and Jellyfin are exempted from the identity gateway on this box. So this + # one path bypasses Authelia. It is safe to: every request carries an HMAC + # signed over (cluster_id, kind), so a leaked URL casts one vote on one + # story rather than acting as a bearer token for the model. + @feedback path /api/feedback/* + handle @feedback { + reverse_proxy news-backend:3000 + } + + # Everything else is browser-facing, so it goes behind Authelia. Tapping the + # digest notification opens a browser, where an interactive login works. + handle { + import authelia + reverse_proxy news-backend:3000 + } } ``` -## 3. Why the feedback buttons need all of this +The change from the current block is deliberate and worth stating plainly: the +vhost moves from **LAN-only** (`tls internal` + `import internal`, which aborts +anything outside 10.0.0.0/24, 10.0.1.0/24, 10.89.0.0/16) to **public**. Without +that, feedback buttons fail on cellular and the digest link is unopenable away +from home -- and silent failure there is precisely how the previous version +ended up with 32 labels in a week. -ntfy executes an action button's HTTP request **from the phone**, not from the -server. So a container-local address can never work, and this is exactly how the -previous version failed: `NEWS_API_BASE_URL` was unset, the buttons were dropped -behind a single boot WARN, and nobody noticed for a week. The result was 32 -feedback labels in total -- for a system whose entire premise is learning from -feedback. +`/srv/news/dist` becomes unused and the `news-update` systemd timer that +populated it can be disabled. -Because the endpoint is therefore public, every button carries an HMAC signed -over `(cluster_id, kind)`. A leaked URL authorises one vote on one story rather -than acting as a bearer token for the whole model. Generate the secret once: +## 3. Scheduling + +``` +*/15 * * * * podman exec news-backend python -m news.cli poll +*/20 * * * * podman exec news-backend python -m news.cli notify +45 8 * * * podman exec news-backend python -m news.cli digest +``` + +## 4. Verifying it actually works ```sh -openssl rand -hex 32 # -> NEWS_FEEDBACK_SECRET in ~/.env +podman exec news-backend python -m news.cli poll # fetch, embed, cluster +podman exec news-backend python -m news.cli simulate # what WOULD be sent +podman exec news-backend python -m news.cli notify --dry-run ``` -`news notify` prints a loud warning and omits the buttons if either -`NEWS_API_BASE_URL` or `NEWS_FEEDBACK_SECRET` is missing, rather than shipping -inert buttons. - -## 4. Scheduling - -Two timers, or one loop inside the container: +Then the one test that matters, because it is the loop that was broken before: +send a real notification, **tap a feedback button on the phone**, and confirm a +row lands in `feedback`: +```sh +podman exec news-backend python -c \ + "import sqlite3;print(sqlite3.connect('/data/news.db').execute('select * from feedback').fetchall())" ``` -*/15 * * * * news poll && news notify -45 8 * * * news digest -``` - -## 5. Running in parallel with news_triage - -Use a different ntfy topic and a different vhost, and leave `news_triage` -running. Compare a week of both before retiring it. diff --git a/news/config.py b/news/config.py index 18ab40c..0bc760d 100644 --- a/news/config.py +++ b/news/config.py @@ -77,7 +77,11 @@ class Config: @property def embedding(self) -> dict[str, Any]: e = self.section("embedding") - return {"model": e.get("model", "BAAI/bge-small-en-v1.5"), "dim": e.get("dim", 384)} + return { + "model": e.get("model", "BAAI/bge-small-en-v1.5"), + "dim": e.get("dim", 384), + "cache_dir": e.get("cache_dir"), + } @property def clustering(self) -> dict[str, Any]: diff --git a/news/embed.py b/news/embed.py index eaeef51..6858acd 100644 --- a/news/embed.py +++ b/news/embed.py @@ -12,6 +12,7 @@ each distinct text is embedded exactly once. from __future__ import annotations +import os import sqlite3 from collections.abc import Sequence @@ -28,21 +29,26 @@ DIM = 384 class Embedder: - def __init__(self, model_name: str = MODEL, dim: int = DIM) -> None: + def __init__(self, model_name: str = MODEL, dim: int = DIM, + cache_dir: str | None = None) -> None: self._model_name = model_name self._dim = dim + # Baked into the image at build time so a cold container does not need + # to reach Hugging Face before it can score anything. + self._cache_dir = cache_dir or os.environ.get("FASTEMBED_CACHE_PATH") self._model = None # loaded lazily: importing fastembed is slow @classmethod def from_config(cls, cfg) -> "Embedder": e = cfg.embedding - return cls(e["model"], e["dim"]) + return cls(e["model"], e["dim"], e.get("cache_dir")) def _load(self): if self._model is None: from fastembed import TextEmbedding - self._model = TextEmbedding(self._model_name) + kwargs = {"cache_dir": self._cache_dir} if self._cache_dir else {} + self._model = TextEmbedding(self._model_name, **kwargs) return self._model def encode(self, texts: Sequence[str]) -> np.ndarray: