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>
7.7 KiB
vote-service
The service's source —
main.go,Dockerfile,go.mod, and thedeploy/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 withgit 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.