first commit
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
# Tech Watch Automation Stack — Miniflux + n8n + digest workflow
|
||||
|
||||
This covers the full stack described in `AGENTS.md` (namespace `watch`,
|
||||
shared PostgreSQL in namespace `postgres`, Miniflux with the initial feed
|
||||
bootstrap, n8n for workflow orchestration, and the daily LLM digest
|
||||
workflow) plus a small read-only file server for the generated digests.
|
||||
Everything below is deployed and active on this cluster.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
k8s/
|
||||
namespace.yaml # namespace: watch (apps)
|
||||
postgres/ # shared PostgreSQL instance (namespace: postgres)
|
||||
namespace.yaml
|
||||
secret.example.yaml # superuser credentials
|
||||
statefulset.yaml
|
||||
service.yaml
|
||||
miniflux/ # namespace: watch (except create-db-job.yaml)
|
||||
secret.example.yaml # DATABASE_URL, admin credentials
|
||||
deployment.yaml
|
||||
service.yaml
|
||||
gateway.yaml # Gateway API (Envoy), HTTPS listener on 443
|
||||
httproute.yaml # routes miniflux.sttlab.pc to the Service
|
||||
create-db-job.yaml # runs in ns postgres: creates the "miniflux" database
|
||||
bootstrap-feeds-job.yaml # runs miniflux-initializer to create categories/feeds
|
||||
n8n/ # namespace: watch (except create-db-job.yaml)
|
||||
secret.example.yaml # DB credentials, N8N_ENCRYPTION_KEY
|
||||
miniflux-token-secret.example.yaml # Miniflux API token, backup/reference for the n8n credential
|
||||
pvc.yaml # n8n-data (n8n's own /home/node/.n8n) + n8n-digest-workspace (/data, for Read/Write File nodes)
|
||||
deployment.yaml
|
||||
service.yaml
|
||||
gateway.yaml # Gateway API (Envoy), HTTPS listener on 443
|
||||
httproute.yaml # routes n8n.sttlab.pc to the Service
|
||||
create-db-job.yaml # runs in ns postgres: creates the "n8n" database
|
||||
digest-files/ # namespace: watch — read-only nginx file server
|
||||
configmap.yaml # nginx.conf: autoindex on
|
||||
deployment.yaml # mounts n8n-digest-workspace PVC (digests/ subdir), read-only
|
||||
service.yaml
|
||||
gateway.yaml # routes digests.sttlab.pc
|
||||
httproute.yaml
|
||||
miniflux-initializer/ # container image used by bootstrap-feeds-job.yaml
|
||||
Dockerfile
|
||||
bootstrap_feeds.py
|
||||
feeds.yaml
|
||||
requirements.txt
|
||||
prompts/
|
||||
digest.md # LLM system prompt for the digest workflow; mounted into n8n via
|
||||
# the n8n-digest-prompt ConfigMap (regenerate after editing, see below)
|
||||
workflows/
|
||||
digest.json # "Tech Watch Digest" n8n workflow (daily 06:00 Europe/Paris cron)
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
1. **Namespaces**
|
||||
```
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/postgres/namespace.yaml
|
||||
```
|
||||
|
||||
2. **PostgreSQL** — `k8s/postgres/{namespace,secret.example,statefulset,service}.yaml`
|
||||
deploy a standalone instance and are kept for reference/portability, but
|
||||
**on this cluster a shared PostgreSQL already runs** in the `postgres`
|
||||
namespace (Bitnami Helm chart, release `postgres`, service
|
||||
`postgres-postgresql`). Reuse it instead of applying the StatefulSet:
|
||||
```
|
||||
cp k8s/postgres/secret.example.yaml k8s/postgres/secret.yaml
|
||||
# edit k8s/postgres/secret.yaml: POSTGRES_PASSWORD must match the real
|
||||
# instance's password:
|
||||
# kubectl get secret postgres-postgresql -n postgres -o jsonpath='{.data.postgres-password}' | base64 -d
|
||||
kubectl apply -f k8s/postgres/secret.yaml
|
||||
kubectl apply -f k8s/miniflux/create-db-job.yaml
|
||||
kubectl wait --for=condition=complete job/create-db-miniflux -n postgres --timeout=60s
|
||||
```
|
||||
(If deploying to a cluster with no existing shared Postgres, apply
|
||||
`statefulset.yaml`/`service.yaml` first and update
|
||||
`create-db-job.yaml`'s host from `postgres-postgresql` to `postgres`.)
|
||||
|
||||
3. **TLS secret** for the Gateway listeners (using the existing wildcard cert for `*.sttlab.pc`)
|
||||
```
|
||||
kubectl create secret tls sttlab-pc-tls \
|
||||
--cert=~/tls/sttlab.pc.crt --key=~/tls/sttlab.pc.key \
|
||||
-n watch
|
||||
```
|
||||
|
||||
4. **Miniflux**
|
||||
```
|
||||
cp k8s/miniflux/secret.example.yaml k8s/miniflux/secret.yaml
|
||||
# edit k8s/miniflux/secret.yaml:
|
||||
# - DATABASE_URL password must match k8s/postgres/secret.yaml
|
||||
# - set ADMIN_USERNAME / ADMIN_PASSWORD for the Miniflux admin account
|
||||
kubectl apply -f k8s/miniflux/secret.yaml
|
||||
kubectl apply -f k8s/miniflux/deployment.yaml
|
||||
kubectl apply -f k8s/miniflux/service.yaml
|
||||
kubectl apply -f k8s/miniflux/gateway.yaml
|
||||
kubectl apply -f k8s/miniflux/httproute.yaml
|
||||
```
|
||||
|
||||
Exposed via Gateway API (Envoy Gateway, `gatewayClassName: envoy`) on
|
||||
port 443 — this cluster's `ingress-nginx` only exposes ports 80/9443
|
||||
externally, not 443, so a plain `Ingress` would not actually be
|
||||
reachable over HTTPS. Point DNS for `miniflux.sttlab.pc` at the Gateway
|
||||
address if not already done (`kubectl get gateway miniflux -n watch`).
|
||||
|
||||
Feed `crawler` (fetch original article content instead of the raw
|
||||
RSS/Atom excerpt) is enabled on all feeds except OpenAI's — its blog is
|
||||
behind a Cloudflare bot challenge that always rejects Miniflux's
|
||||
fetcher, so it stays on the RSS excerpt. See `crawler:` per feed in
|
||||
`miniflux-initializer/feeds.yaml`.
|
||||
|
||||
5. **Bootstrap feeds** — runs as a one-off Job using a locally built image
|
||||
(no external registry needed on a single-node k3s cluster):
|
||||
```
|
||||
cd miniflux-initializer
|
||||
docker build --network=host -t miniflux-initializer:latest .
|
||||
docker save miniflux-initializer:latest | sudo k3s ctr images import -
|
||||
cd ..
|
||||
kubectl apply -f k8s/miniflux/bootstrap-feeds-job.yaml
|
||||
kubectl wait --for=condition=complete job/bootstrap-feeds -n watch --timeout=60s
|
||||
kubectl logs -n watch job/bootstrap-feeds
|
||||
```
|
||||
Safe to re-run: delete the Job (`kubectl delete job bootstrap-feeds -n
|
||||
watch`) and re-apply after editing `miniflux-initializer/feeds.yaml`
|
||||
(rebuild + re-import the image first if you changed it). Only adds new
|
||||
feeds — it does not update `crawler`/settings on feeds that already
|
||||
exist (use the Miniflux API or UI for that).
|
||||
|
||||
> Note: `--network=host` is required for the `pip install` step during
|
||||
> the image build — the local router does not answer DNS queries from
|
||||
> Docker's bridge subnet, only the host's own resolver works.
|
||||
|
||||
6. **API token for n8n**: create it manually in the Miniflux UI under
|
||||
*Settings → API Keys* — there is no public REST endpoint to create one.
|
||||
Kept as the source of truth in `k8s/n8n/miniflux-token-secret.yaml` (see
|
||||
`.example` for the format); the n8n credential itself is created in step
|
||||
8 below, from the same token.
|
||||
|
||||
7. **n8n**
|
||||
```
|
||||
cp k8s/n8n/secret.example.yaml k8s/n8n/secret.yaml
|
||||
# edit k8s/n8n/secret.yaml:
|
||||
# - DB_POSTGRESDB_PASSWORD must match k8s/postgres/secret.yaml
|
||||
# - N8N_ENCRYPTION_KEY: generate once with `openssl rand -hex 24` and
|
||||
# never rotate it afterwards (it decrypts every saved credential)
|
||||
kubectl apply -f k8s/n8n/secret.yaml
|
||||
kubectl apply -f k8s/n8n/create-db-job.yaml
|
||||
kubectl wait --for=condition=complete job/create-db-n8n -n postgres --timeout=60s
|
||||
kubectl apply -f k8s/n8n/pvc.yaml
|
||||
# ConfigMap holding the digest LLM prompt (prompts/digest.md is the source
|
||||
# of truth; regenerate and re-apply this any time the file changes):
|
||||
kubectl create configmap n8n-digest-prompt --from-file=digest.md=prompts/digest.md \
|
||||
-n watch --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl apply -f k8s/n8n/deployment.yaml
|
||||
kubectl apply -f k8s/n8n/service.yaml
|
||||
kubectl apply -f k8s/n8n/gateway.yaml
|
||||
kubectl apply -f k8s/n8n/httproute.yaml
|
||||
```
|
||||
Same Gateway API setup as Miniflux (TLS terminated by Envoy Gateway on
|
||||
443, reusing the `sttlab-pc-tls` secret). Point DNS for `n8n.sttlab.pc`
|
||||
at the Gateway address once (`kubectl get gateway n8n -n watch`).
|
||||
|
||||
On first login, n8n prompts to create the owner account (email/password)
|
||||
— there's no `CREATE_ADMIN`-style env var like Miniflux, so this step is
|
||||
manual in the UI.
|
||||
|
||||
Two PVCs are mounted: `n8n-data` is n8n's own `/home/node/.n8n` (config,
|
||||
binary data cache); `n8n-digest-workspace` is mounted separately at
|
||||
`/data` for the digest workflow's Read/Write File nodes (prompt +
|
||||
generated digests). They're kept apart because n8n unconditionally
|
||||
blocks file-node access to its own `/home/node/.n8n` directory
|
||||
(`N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES`, not overridable via
|
||||
`N8N_RESTRICT_FILE_ACCESS_TO`) — a dedicated volume avoids that
|
||||
restriction honestly instead of working around it. Note also that
|
||||
`N8N_RESTRICT_FILE_ACCESS_TO` only accepts a single path, not a list —
|
||||
hence both `prompts/` and `digests/` live under the same `/data` root.
|
||||
|
||||
8. **Digest workflow** — import `workflows/digest.json` (the "Tech Watch
|
||||
Digest" workflow: daily 06:00 Europe/Paris cron) and wire its
|
||||
credentials via the n8n Public API (Settings → n8n API → create a key
|
||||
first):
|
||||
```
|
||||
N8N_KEY=<your n8n API key>
|
||||
|
||||
# Miniflux credential (Header Auth, X-Auth-Token) — use the same token
|
||||
# as k8s/n8n/miniflux-token-secret.yaml
|
||||
curl -s https://n8n.sttlab.pc/api/v1/credentials -X POST \
|
||||
-H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Miniflux API","type":"httpHeaderAuth","data":{"name":"X-Auth-Token","value":"<token>","allowedHttpRequestDomains":"all"}}'
|
||||
|
||||
# LLM credential (Header Auth, Authorization: Bearer <key>) — OpenRouter or
|
||||
# any OpenAI-compatible provider
|
||||
curl -s https://n8n.sttlab.pc/api/v1/credentials -X POST \
|
||||
-H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"name":"LLM API","type":"httpHeaderAuth","data":{"name":"Authorization","value":"Bearer <key>","allowedHttpRequestDomains":"all"}}'
|
||||
|
||||
# Import the workflow, then edit the two credential IDs in its HTTP
|
||||
# Request nodes ("Get Unread Entries" / "Mark Entries As Read" -> Miniflux
|
||||
# API; "Summarize Category" -> LLM API) to match the IDs returned above,
|
||||
# and set the real llmBaseUrl / llmModel in the "Init" node's code.
|
||||
curl -s https://n8n.sttlab.pc/api/v1/workflows -X POST \
|
||||
-H "X-N8N-API-KEY: $N8N_KEY" -H "Content-Type: application/json" \
|
||||
--data-binary @<(python3 -c "import json; wf=json.load(open('workflows/digest.json')); print(json.dumps({k: wf[k] for k in ('name','nodes','connections','settings')}))")
|
||||
|
||||
# Activate once credentials are wired and a manual run succeeds:
|
||||
curl -s https://n8n.sttlab.pc/api/v1/workflows/<id>/activate -X POST \
|
||||
-H "X-N8N-API-KEY: $N8N_KEY"
|
||||
```
|
||||
The workflow uses Miniflux's **internal** cluster Service DNS
|
||||
(`http://miniflux.watch.svc.cluster.local/v1/entries`), not the external
|
||||
HTTPS hostname — `miniflux.sttlab.pc` isn't resolvable from inside the
|
||||
cluster (it only exists as a Gateway route, no DNS record).
|
||||
|
||||
To test-run before activating (the running Deployment pod can't run
|
||||
`n8n execute` itself — its Task Broker port is already bound — so use a
|
||||
throwaway Job with the same image/env/volumes instead), or just use the
|
||||
"Test workflow" button in the n8n UI from the Schedule Trigger node.
|
||||
|
||||
Pipeline: fetch unread Miniflux entries from the last 24h → group by
|
||||
category → for each category, call the LLM (system prompt from
|
||||
`prompts/digest.md`) to pick the 3-5 most relevant items + weak signals,
|
||||
strict JSON output → compose one Markdown file → write it to
|
||||
`/data/digests/digest-<date>.md` → mark the processed entries as read in
|
||||
Miniflux.
|
||||
|
||||
9. **Digest file server** — serves `/data/digests/` (read-only) so digests
|
||||
can be browsed/downloaded from a browser instead of email:
|
||||
```
|
||||
kubectl apply -f k8s/digest-files/
|
||||
```
|
||||
Same Gateway/TLS pattern, routes `digests.sttlab.pc`. Point DNS at the
|
||||
Gateway address (`kubectl get gateway digest-files -n watch`).
|
||||
|
||||
## How to add a source
|
||||
|
||||
Add an entry under the relevant category (or a new category) in
|
||||
`miniflux-initializer/feeds.yaml`, verify the URL responds with `curl -I
|
||||
<url>`, then rebuild the image and re-run the bootstrap Job (see step 5).
|
||||
|
||||
## Validate manifests
|
||||
|
||||
```
|
||||
kubectl apply --dry-run=client -f k8s/namespace.yaml
|
||||
kubectl apply --dry-run=client -f k8s/postgres/
|
||||
kubectl apply --dry-run=client -f k8s/miniflux/
|
||||
kubectl apply --dry-run=client -f k8s/n8n/
|
||||
kubectl apply --dry-run=client -f k8s/digest-files/
|
||||
```
|
||||
|
||||
(Run against a cluster with the `watch`/`postgres` namespaces and secrets
|
||||
already created, since some manifests reference them.)
|
||||
|
||||
## Smoke-test checklist
|
||||
|
||||
- [ ] `kubectl get pods -n postgres` — postgres pod `Running`, `1/1` ready
|
||||
- [ ] `kubectl get pods -n watch` — miniflux pod `Running`, `1/1` ready
|
||||
- [ ] `kubectl logs -n watch deploy/miniflux` shows migrations applied, no errors
|
||||
- [ ] `https://miniflux.sttlab.pc/healthcheck` returns `OK`
|
||||
- [ ] Log in to the Miniflux UI with the admin credentials from the secret
|
||||
- [ ] `kubectl logs -n watch job/bootstrap-feeds` shows categories/feeds created
|
||||
- [ ] Categories and feeds appear in the Miniflux UI
|
||||
- [ ] At least one feed shows fetched entries (Miniflux polls periodically; use "Refresh" in the UI to force it)
|
||||
- [ ] `GET /v1/entries?status=unread` (with an API token) returns entries
|
||||
- [ ] `kubectl get pods -n watch` — n8n pod `Running`, `1/1` ready
|
||||
- [ ] `kubectl logs -n watch deploy/n8n` shows no DB connection errors
|
||||
- [ ] `https://n8n.sttlab.pc/healthz` returns `{"status":"ok"}`
|
||||
- [ ] Log in to the n8n UI and create the owner account
|
||||
- [ ] n8n can reach Miniflux: an HTTP Request node to
|
||||
`http://miniflux.watch.svc.cluster.local/v1/me` with the API token
|
||||
returns the admin user
|
||||
- [ ] "Tech Watch Digest" workflow imported, both credentials (Miniflux API,
|
||||
LLM API) attached to their HTTP Request nodes
|
||||
- [ ] A manual run ("Test workflow" in the UI, or a throwaway
|
||||
`n8n execute --id=<id>` Job) produces a file under `/data/digests/`
|
||||
in the n8n pod, and the corresponding Miniflux entries are marked read
|
||||
- [ ] Workflow activated (`"active": true`)
|
||||
- [ ] `kubectl get pods -n watch` — digest-files pod `Running`, `1/1` ready
|
||||
- [ ] `https://digests.sttlab.pc/` lists the generated digest files
|
||||
|
||||
## Current status
|
||||
|
||||
Deployed and active: Miniflux (9 feeds), n8n, "Tech Watch Digest" workflow
|
||||
(daily 06:00 Europe/Paris, OpenRouter `deepseek/deepseek-v4-flash`), and
|
||||
the digest file server at `https://digests.sttlab.pc/`. No email delivery
|
||||
(SMTP not configured) — digests are Markdown files served over HTTP
|
||||
instead.
|
||||
Reference in New Issue
Block a user