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>
This commit is contained in:
+11
-10
@@ -27,7 +27,7 @@ worktree practices taught by the presentation fit together.
|
||||
| Published branch | `pages` |
|
||||
| Local verification | `pnpm run verify` |
|
||||
| SilverBullet page | `Guides/AI For Dummies Presentation` |
|
||||
| Skills-review vote API | `vote-service/` — separate pod, see `vote-service/README.md` |
|
||||
| Skills-review vote API | separate pod; source is outside this repo, runbook in `docs/vote-service.md` |
|
||||
|
||||
## How the site is built
|
||||
|
||||
@@ -174,14 +174,16 @@ 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.
|
||||
site, so it cannot itself remember votes. A separate Go API on its own pod does
|
||||
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.
|
||||
|
||||
The service's source is no longer in this repository; run these from wherever it
|
||||
now lives.
|
||||
|
||||
```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
|
||||
|
||||
@@ -209,14 +211,13 @@ 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.
|
||||
`src/pages/skills-review.astro` 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).
|
||||
and the anti-abuse design are in [docs/vote-service.md](vote-service.md).
|
||||
|
||||
## Adding or changing a presentation section
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```bash
|
||||
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`):
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user