docs: housekeeping - branch policy, Tailscale sidecar deployment (no host coupling), dedicated import cron plan

This commit is contained in:
AlexanderRozhenko
2026-11-10 18:09:16 -04:00
parent 18bf31f361
commit 670648b17a
3 changed files with 24 additions and 12 deletions

View File

@@ -16,7 +16,7 @@ Architecture for v1 (single source, single user, tailnet-only PWA). Written 2026
```
08:00 motorcycle-hunt crawl (existing, unchanged) → state JSONs: kijiji-ns.json, kijiji-nb.json
08:15 importer (script-only, zero LLM):
08:30 importer — script-only, zero LLM (Hermes cron `bike-tinder-import` → POST /api/import):
gates → upsert → dedupe → score → gone-marking → image queue
→ SQLite (WAL)
Deck phone ⇄ API ⇄ SQLite
@@ -33,9 +33,9 @@ Deck phone ⇄ API ⇄ SQLite
- **Scoring:** rubric tables ported from `reference/motorcycle-hunt-rank.py` (family tiers + extras + veto) — the score is Glenn's accumulated taste, computed at import, stored on each row. See `RANKING.md`.
- **Idempotent:** safe to re-run any time; also exposed as `POST /api/import` for manual triggers.
## Scheduling 【decided】
## Scheduling 【updated 2026-11-10 — Glenn's call】
An in-container daily task (asyncio, 08:15 America/Halifax) inside the API process, with on-boot catch-up for missed runs. No host crontab is involved (the host user crontab is empty; the 08:00 crawl lives in Hermes cron — the app depends on neither). Every run writes an `import_runs` row; `GET /api/stats` surfaces the last run.
The nightly import is triggered by its own Hermes cron job — `bike-tinder-import`, **08:30 America/Halifax** (after the 08:00 hunt), run **script-only (zero LLM tokens)** and silent on success. The hunt's own cron is untouched. The container additionally keeps an **on-boot catch-up** (runs once if the last import is >26 h stale). Every path calls the same idempotent importer and writes an `import_runs` row; `GET /api/stats` surfaces the last run. (The cron job is created now, parked; it wakes at first deploy — Phase 4.)
## API surface (v1)
@@ -47,7 +47,7 @@ An in-container daily task (asyncio, 08:15 America/Halifax) inside the API proce
| `POST /api/swipe` `{id, action}` | record or edit like / nope / maybe (upsert) |
| `DELETE /api/swipe/{id}` | clear a call (returns the card to the deck) |
| `GET /api/list?filter=&q=&offset=` | full list, filters + search |
| `POST /api/import` | manual import trigger |
| `POST /api/import` | manual import trigger (also the cron hook) |
| `GET /img/{id}` | cached image file, else 302 to source CDN |
**Deck query:** `status='active'`, not noped, and either no swipe or a `maybe` older than 7 days — ordered `score DESC, first_seen DESC`. Unscored rows sort below scored ones but still appear — nothing is hidden.
@@ -64,15 +64,19 @@ No-build SPA — vanilla JS + CSS, pointer-events drag, keyboard arrows on deskt
## Deployment (house constraints)
- **Stack dir:** `/var/mnt/DATA/podman-stacks/bike-tinder/` — `docker-compose.yml` + `Dockerfile` (`python:3.14-slim` + fastapi/uvicorn/requests), volume `./data:/data`, port `127.0.0.1:9453:8000`, `restart: unless-stopped`.
- **Stack dir:** `/var/mnt/DATA/podman-stacks/bike-tinder/` — `docker-compose.yml` + `Dockerfile` (`python:3.14-slim` + fastapi/uvicorn/requests), volume `./data:/data`, `restart: unless-stopped`.
- **App networking:** the app binds plain HTTP on the compose network and publishes **localhost-only** (`127.0.0.1:9453:8000`) — that's how the nightly trigger and local debugging reach it. No LAN exposure.
- **Tailscale sidecar — baked into the stack** (the repo carries **no host-tailnet coupling**; the app itself knows nothing about Tailscale):
- a `tailscale/tailscale` container joins the tailnet as its own node and serves the app at **`https://biketinder.tailf35a34.ts.net`** (TCP 443 via `tailscale serve` → `http://app:8000` over the compose network);
- auth: a deploy-time auth key minted by Glenn, stored only in the stack's local `.env` — never committed, never pasted anywhere else;
- deploy-time checks: the tailnet ACL must allow Glenn's devices to reach the `biketinder` node; first verification happens from his phone (the tailnet name can't be curled from this host).
- After adding files: `chcon -Rt container_file_t /var/mnt/DATA/podman-stacks/bike-tinder`.
- **Systemd user unit** `podman-stack-bike-tinder.service`, cloned from the machine's `podman-stack-searxng.service` pattern: `Type=oneshot`, `RemainAfterExit=yes`, **`Delegate=yes`** (keeps the stack out of the hermes-gateway cgroup so gateway restarts can't SIGKILL it); ExecStart/ExecStop = the venv `podman-compose up -d` / `down`.
- **Tailnet:** `tailscale serve --bg 9453` → reachable at `http://rotarran.tailf35a34.ts.net:9453`, tailnet-only. The tailnet name can't be curled from this host — first deploy verification happens from Glenn's phone.
- **Backups:** nightly `sqlite3 .backup` into `data/backup/` right after import (the DB is the only mutable state; house rule). Restore = stop stack, swap the file, start.
## Security
Single user; **no auth in v1** — localhost bind + tailnet-only reachability. An optional `AUTH_TOKEN` env var is noted as a one-line later hardening. No secrets are required in the stack.
Single user; **no auth in v1** — the app is reached only through the stack's own Tailscale node (plus a localhost-only port for triggers and debugging). An optional `AUTH_TOKEN` env var is noted as a one-line later hardening. One deploy-time secret exists in the stack: the Tailscale auth key (local `.env` only).
## Risks & mitigations (from consultation)
@@ -90,7 +94,7 @@ Single user; **no auth in v1** — localhost bind + tailnet-only reachability. A
```
app/ importer.py · api.py · static/{index.html,app.js,app.css,manifest.json,sw.js,icons}
deploy/ docker-compose.yml · Dockerfile · podman-stack-bike-tinder.service
deploy/ docker-compose.yml (app + Tailscale sidecar) · Dockerfile · podman-stack-bike-tinder.service
reference/ copies of the hunt modules the app vendors from
data/ runtime state (gitignored)
docs/ this design set