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>
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
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:
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:
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.
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):
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.