Files
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
..

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.