Files
ai-for-dummies/docs/vote-service.md
T
Marcos Paulo 9015e7bd1d
verify-and-publish / gate (push) Successful in 14m4s
verify-and-publish / publish (push) Has been skipped
chore: take vote-service out of the repository root
Removes the Go source, Dockerfile, go.mod, and Kubernetes manifests. The
deployed service is untouched and the review desk still calls it over
window.SKILLS_REVIEW_VOTE_API; only the source leaves.

The runbook does not leave. vote-service/README.md moves to
docs/vote-service.md, because it carries the parts that are hard to
rediscover: why the ingress overwrites X-Forwarded-For and Caddy stamps
X-Client-IP instead, why the image is side-loaded into containerd rather
than pulled, and why the PVC pins the Deployment to one node.

This drops verify.mjs from 84 assertions to 83. The removed one read
vote-service/main.go for X-Forwarded-For and 'one active vote per skill'
-- the review desk's only anti-abuse control -- and there is no file left
to read. It is the first assertion this repository has ever lost.

Rather than lower the gate's floor and leave a bare number behind,
gate.sh now subtracts the number of entries in
.agents/context/assertion-removals.md from the baseline. A removal costs
a written reason in a tracked file, in the same commit, as a visible
diff. Tested at 82 assertions: still refused.

Also drops the 22 MB of PNG baselines under .agents/snapshots/before/ and
before-reduced-motion/. They pictured the hand-written site, which no
longer exists; visual-regression.mjs has no compare mode to diff them
against; and they are recoverable from d88d8b8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 08:59:45 +00:00

7.7 KiB

vote-service

The service's source — main.go, Dockerfile, go.mod, and the deploy/ manifests — was removed from this repository on 2026-09-06. This runbook stays because the deployed service is unchanged and the review desk still calls it. Recover the source with git show <pre-removal-sha>: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.