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).
This commit is contained in:
1 parent
dc6b6fea7e
commit
a001a09fdf
3 files changed
+93
-49
No files matched your search
+79
-45
@@ -1,86 +1,120 @@
|
|||||||
# Deploying
|
# Deploying
|
||||||
|
|
||||||
Three things must be true before the system is useful, and the third is the one
|
This replaces the `news-backend` service (from `../news_triage`) in place: same
|
||||||
the previous version got wrong.
|
host, same vhost, same ntfy topic.
|
||||||
|
|
||||||
## 1. Compose service
|
## 1. Compose
|
||||||
|
|
||||||
|
Replace the `news-backend` block in `~/compose.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
news:
|
news-backend:
|
||||||
image: git.rcjohnstone.com/connor/news:latest
|
image: git.rcjohnstone.com/connor/news:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [internal]
|
networks:
|
||||||
|
- internal
|
||||||
environment:
|
environment:
|
||||||
- TZ=America/Louisville
|
- TZ=America/Louisville
|
||||||
- NEWS_DB=/data/news.db
|
- NEWS_DB=/data/news.db
|
||||||
- NEWS_CONFIG_DIR=/config
|
- NEWS_CONFIG_DIR=/config
|
||||||
# ntfy. The topic must differ from news_triage's during the parallel run.
|
|
||||||
- NTFY_URL=${NTFY_URL}
|
- 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_USER=${NTFY_USER}
|
||||||
- NTFY_PASS=${NTFY_PASS}
|
- NTFY_PASS=${NTFY_PASS}
|
||||||
# REQUIRED for feedback buttons -- see section 3.
|
# REQUIRED for feedback buttons. ntfy runs http actions FROM THE PHONE, so
|
||||||
- NEWS_API_BASE_URL=https://news2.rcjohnstone.com
|
# 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}
|
- NEWS_FEEDBACK_SECRET=${NEWS_FEEDBACK_SECRET}
|
||||||
# One LLM call per day, for the digest prose only.
|
# One LLM call per day, for the digest prose only.
|
||||||
- NEWS_LITELLM_KEY=${NEWS_LITELLM_KEY}
|
- NEWS_LITELLM_KEY=${NEWS_LITELLM_KEY}
|
||||||
volumes:
|
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
|
- ./config/news2:/config:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# NOT curl. The v0.2 image shipped no curl, so its healthcheck failed
|
# NOT curl: the v0.2 image shipped none, so its healthcheck failed forever
|
||||||
# forever while the service was fine and `podman ps` lied for four days.
|
# while the service was fine and `podman ps` reported it unhealthy for days.
|
||||||
test: ["CMD", "python", "-c",
|
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
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
```
|
```
|
||||||
|
|
||||||
Remember that a bind-mounted config change needs `sudo podman restart news`;
|
Copy `profile/*.toml` to `~/config/news2/`. A bind-mounted config edit needs
|
||||||
`podman-compose up -d` will not restart a container when only a mounted file
|
`sudo podman restart news-backend`; `podman-compose up -d` will not restart a
|
||||||
changed. An **environment** change does need the full `up -d`.
|
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 {
|
# ddns: public
|
||||||
reverse_proxy news:3000
|
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
|
`/srv/news/dist` becomes unused and the `news-update` systemd timer that
|
||||||
server. So a container-local address can never work, and this is exactly how the
|
populated it can be disabled.
|
||||||
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.
|
|
||||||
|
|
||||||
Because the endpoint is therefore public, every button carries an HMAC signed
|
## 3. Scheduling
|
||||||
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:
|
```
|
||||||
|
*/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
|
```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
|
Then the one test that matters, because it is the loop that was broken before:
|
||||||
`NEWS_API_BASE_URL` or `NEWS_FEEDBACK_SECRET` is missing, rather than shipping
|
send a real notification, **tap a feedback button on the phone**, and confirm a
|
||||||
inert buttons.
|
row lands in `feedback`:
|
||||||
|
|
||||||
## 4. Scheduling
|
|
||||||
|
|
||||||
Two timers, or one loop inside the container:
|
|
||||||
|
|
||||||
|
```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.
|
|
||||||
+5
-1
@@ -77,7 +77,11 @@ class Config:
|
|||||||
@property
|
@property
|
||||||
def embedding(self) -> dict[str, Any]:
|
def embedding(self) -> dict[str, Any]:
|
||||||
e = self.section("embedding")
|
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
|
@property
|
||||||
def clustering(self) -> dict[str, Any]:
|
def clustering(self) -> dict[str, Any]:
|
||||||
|
|||||||
+9
-3
@@ -12,6 +12,7 @@ each distinct text is embedded exactly once.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
@@ -28,21 +29,26 @@ DIM = 384
|
|||||||
|
|
||||||
|
|
||||||
class Embedder:
|
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._model_name = model_name
|
||||||
self._dim = dim
|
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
|
self._model = None # loaded lazily: importing fastembed is slow
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, cfg) -> "Embedder":
|
def from_config(cls, cfg) -> "Embedder":
|
||||||
e = cfg.embedding
|
e = cfg.embedding
|
||||||
return cls(e["model"], e["dim"])
|
return cls(e["model"], e["dim"], e.get("cache_dir"))
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
if self._model is None:
|
if self._model is None:
|
||||||
from fastembed import TextEmbedding
|
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
|
return self._model
|
||||||
|
|
||||||
def encode(self, texts: Sequence[str]) -> np.ndarray:
|
def encode(self, texts: Sequence[str]) -> np.ndarray:
|
||||||
|
|||||||
Reference in new issue
Block a user