Files
ai-for-dummies/vote-service/README.md
Marcos Paulo e2bcfff5ab feat: add reader vote widget and vote-service
The skills-review desk is static, so "which draft would you ship?" needs a
stateful counterpart. vote-service is a small Go API on its own pod backed by
a JSON file on a ReadWriteOnce PVC, with one active vote per skill per source
IP as the anti-abuse rule and CORS (ALLOWED_ORIGIN) as the caller boundary.

Deployment notes that differ from the obvious path, all confirmed against the
live cluster: the image is side-loaded with `ctr image import` plus
`imagePullPolicy: Never` because kubelet has no credentials for the Nexus ref;
the pod is pinned to `kubernets` because the hostpath PV takes a nodeAffinity
for whichever node first binds it; and public exposure is Caddy on the VPS,
not the cloudflared tunnel.

The ingress controller runs with `use-forwarded-headers` off, so nginx
overwrites X-Forwarded-For with its own peer — every visitor would collapse
into one voter and each skill would cap at one vote overall. Caddy stamps the
true remote address into X-Client-IP, which nginx forwards untouched, and
clientIP() reads that first. Scoped to this app rather than flipping the
global flag, which would change client-IP handling for every other ingress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:49:55 +00:00

153 lines
6.8 KiB
Markdown

# vote-service
Tiny Go HTTP API backing the "prefer original / prefer improved" vote widget
on `skills-review/`. One binary, no external dependencies, one JSON file on
disk as the store — proportionate to workshop-scale traffic, not a general
voting platform.
## Why a separate service
`netcracker.pages.marcospaulo.dev.br` is a static Pages Server: it serves
files, it cannot run server code or remember state. Any real vote count needs
a small stateful service reachable from the visitor's browser, so this lives
outside the static repo and runs as its own pod.
## Anti-abuse: IP, not MAC
A MAC address is a link-layer detail; it never reaches a server across the
internet, so it cannot be used here. "Same source" is approximated by client
IP (`X-Forwarded-For` / `X-Real-IP` behind the ingress, else the raw remote
address). One IP holds at most one active vote per skill — casting again
updates that vote instead of stacking a second one. This is imperfect (NAT,
VPNs, shared networks collapse to one vote; IP changes let someone vote
again) but matches the ask and needs no cookies, accounts, or client secrets.
A `X-Voter-Id` header (a random id the frontend keeps in `localStorage`) is
layered on only so a browser can display "you already voted X" — it is never
trusted as the sole anti-abuse signal, since `localStorage` is trivially
resettable.
## API
| Method | Path | Body | Response |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/votes` | — | `{ "tallies": { "<skillId>": { "original": n, "improved": n } } }` |
| `GET` | `/api/votes?skillId=X` | — | adds `"you": "original"\|"improved"` when the caller's IP already voted on `X` |
| `POST` | `/api/votes` | `{"skillId":"X","choice":"original"\|"improved"}` | `{"skillId","original","improved","you"}` |
| `GET` | `/healthz` | — | `200` |
## Run locally
```bash
go run . # PORT=8080 VOTE_DB_PATH=/tmp/votes.json ALLOWED_ORIGIN=http://localhost:4173
```
## Build and publish the image
Pushed to this cluster's Nexus registry (docker-hosted repo, anonymous read
already enabled cluster-wide — no `imagePullSecrets` needed). Push host and
pull host differ because Nexus is reached from a workstation via its NodePort
but from inside the cluster via its Service DNS name:
```bash
docker build -t localhost:30892/ai-for-dummies-vote-service:latest .
docker push localhost:30892/ai-for-dummies-vote-service:latest
# pods pull the same image as: nexus-service.nexus.svc.cluster.local:8082/ai-for-dummies-vote-service:latest
```
## Deploy (microk8s)
The `ai-for-dummies-vote-data` PVC uses `microk8s-hostpath`, whose PVs carry a
`nodeAffinity` for whichever node first binds them — so scheduling and storage
must agree on one node. This runs on `kubernets` (the control-plane node that
hosts the rest of the cluster's workloads), pinned via `nodeSelector` in
`deployment.yaml`.
kubelet's image pulls run in the *host* network namespace and there is no
`certs.d/hosts.toml` entry for `localhost:30892`, so a plain pull of the Nexus
ref fails (`no basic auth credentials`). Push to Nexus for a durable off-node
copy, then import straight into that node's containerd store and let
`imagePullPolicy: Never` skip the network pull entirely — the same pattern the
`pragent-webhook` image uses in this cluster:
```bash
docker save localhost:30892/ai-for-dummies-vote-service:latest -o /tmp/vote-service.tar
/snap/microk8s/current/bin/ctr --address /var/snap/microk8s/common/run/containerd.sock \
--namespace k8s.io image import /tmp/vote-service.tar
# use microk8s's own bundled ctr, not the host's — different containerd major
# versions speak incompatible client/server protocols (`unknown service
# containerd.services.streaming.v1.Streaming` otherwise)
microk8s kubectl apply -f deploy/deployment.yaml # namespace + Deployment + PVC + Service
microk8s kubectl apply -f deploy/ingress.yaml
microk8s kubectl -n ai-for-dummies rollout restart deploy ai-for-dummies-vote
```
Re-run the `docker save`/`ctr image import` pair after every image rebuild —
`imagePullPolicy: Never` means the cluster never fetches a newer tag on its
own, and a `rollout restart` is what picks the new image up.
## Public exposure
Public traffic reaches the cluster through **Caddy on the Oracle VPS over
Tailscale**, which is how all ~21 public hosts in this account are served
(`langfuse`, `pragent-dashboard`, `vault`, …) — *not* through the cloudflared
tunnel. The tunnel's public-hostname routes are dashboard-managed and the DNS
API token cannot write them, so the Caddy path is also the only one that can
be automated end to end.
```bash
cf-dns add ai-for-dummies-vote A 129.148.56.8 # DNS-only (grey cloud), like every other Caddy host
```
Caddy block (`/etc/caddy/Caddyfile` on the VPS, local copy `~/scripts/Caddyfile`):
```caddyfile
ai-for-dummies-vote.marcospaulo.dev.br {
tls {
dns cloudflare <CF_TOKEN>
}
reverse_proxy 100.74.17.70:80 {
header_up Host {host}
header_up X-Client-IP {remote_host}
}
}
```
It proxies to port `80` (not a NodePort): the cluster's nginx ingress runs on
`hostNetwork` on `kubernets` and routes by `Host`.
### Why `X-Client-IP`
The ingress controller runs with `use-forwarded-headers` **off** (the microk8s
default — `nginx-load-balancer-microk8s-conf` has no `data`). nginx therefore
*overwrites* `X-Forwarded-For` and `X-Real-IP` with its own downstream peer,
which is the VPS's tailnet address `100.67.25.57`. Every visitor would collapse
into one voter, and since one IP holds at most one active vote per skill, each
skill would only ever hold a single vote in total — the anti-abuse rule would
silently become a hard cap.
Rather than flip `use-forwarded-headers` globally (it would change client-IP
handling for every other ingress in the cluster), Caddy stamps the true remote
address into `X-Client-IP`, a non-standard header nginx forwards untouched, and
`clientIP()` reads it first. `header_up` sets it unconditionally, so a public
client cannot spoof it; the trust placed in it is exactly the trust already
placed in `X-Forwarded-For`.
Verified after deploy: requests from two distinct sources are recorded as two
separate votes rather than overwriting one another.
## Frontend wiring
`skills-review/index.html` sets `window.SKILLS_REVIEW_VOTE_API` to
`https://ai-for-dummies-vote.marcospaulo.dev.br`; keep it in sync with
`ALLOWED_ORIGIN` in `deployment.yaml`
(`https://netcracker.pages.marcospaulo.dev.br`), which is the real caller
boundary — CORS restricts which origin's browser code may call the API, not
which network can reach it.
`replicas: 1` and `strategy: Recreate` are deliberate: the store is one file
on one `ReadWriteOnce` PVC, so two pods writing it concurrently would race.
Scale up only after moving the store to something that supports concurrent
writers (e.g. SQLite on a shared volume with proper locking, or Postgres) —
not needed at this traffic scale.