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>
This commit is contained in:
Marcos Paulo
2026-09-04 23:49:55 +00:00
parent d12d301a1a
commit e2bcfff5ab
14 changed files with 714 additions and 8 deletions
+49
View File
@@ -27,6 +27,7 @@ worktree practices taught by the presentation fit together.
| Published branch | `pages` |
| Local verification | `npm run verify` |
| SilverBullet page | `Guides/AI For Dummies Presentation` |
| Skills-review vote API | `vote-service/` — separate pod, see `vote-service/README.md` |
## How the site is built
@@ -148,6 +149,54 @@ https://netcracker.pages.marcospaulo.dev.br/ai-for-dummies/
produce `ERR_SSL_PROTOCOL_ERROR` because it does not match the wildcard TLS
certificate.
## Skills-review vote service
`skills-review/` is served by the same static Pages Server as the rest of
this site, so it cannot itself remember votes. `vote-service/` is a separate
Go API on its own pod for that: one JSON file as the store, one vote per
visitor enforced by IP (a MAC address never reaches a server across the
internet). It is deployed independently of `main`/`pages` — the site can be
republished without touching it, and vice versa.
```bash
cd vote-service
docker build -t localhost:30892/ai-for-dummies-vote-service:latest .
docker push localhost:30892/ai-for-dummies-vote-service:latest
# kubelet cannot pull that ref (no certs.d/hosts.toml for localhost:30892 →
# `no basic auth credentials`), so side-load into containerd instead and let
# `imagePullPolicy: Never` skip the network pull. Use microk8s's bundled ctr.
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
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
```
Namespace `ai-for-dummies`, `ingressClassName: public`, no per-ingress TLS.
The Deployment is pinned to node `kubernets` with a `nodeSelector`: the
`microk8s-hostpath` PV carries a `nodeAffinity` for whichever node first binds
it, so scheduling and storage have to agree on one node.
The vote widget's browser-side `fetch` calls must reach the API over the public
internet — a cluster-internal-only Service would be unreachable from a
visitor's browser even if the Pages Server happens to run on the same
network. Exposure is therefore public, terminated by **Caddy on the Oracle VPS
over Tailscale** (the same path as every other public host here, not the
cloudflared tunnel), with `ALLOWED_ORIGIN`/CORS as the boundary that restricts
which site's script may call it. After deploying, keep
`window.SKILLS_REVIEW_VOTE_API` in `skills-review/index.html` in sync with
`ALLOWED_ORIGIN` on the service.
One cluster-wide gotcha worth knowing before reading the vote code: the ingress
controller runs with `use-forwarded-headers` off, so nginx *overwrites*
`X-Forwarded-For`/`X-Real-IP` with the VPS's tailnet address. Caddy stamps the
true client address into `X-Client-IP` instead. Full rationale, the Caddy block,
and the anti-abuse design are in
[vote-service/README.md](../vote-service/README.md).
## Adding or changing a presentation section
1. Add semantic HTML and stable `data-*` hooks in the focused chapter or `full-guide/index.html`; keep `index.html` as the short route map.