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:
@@ -0,0 +1,106 @@
|
||||
# Namespace, image ref, and storage class confirmed against this cluster
|
||||
# (microk8s, 2026-09-04). Image is pushed to Nexus for a durable, off-node
|
||||
# copy (docker push localhost:30892/... — see README), but the Deployment
|
||||
# below pulls it from the *node's local containerd image store* instead of
|
||||
# over the network: kubelet's image pulls run in the host network namespace,
|
||||
# which uses this node's public DNS resolver, not cluster CoreDNS, so
|
||||
# `nexus-service.nexus.svc.cluster.local` is NOT resolvable for a plain pull
|
||||
# (only for in-cluster builders like Kaniko, whose *build* pod runs in pod
|
||||
# netns). The `microk8s-hostpath` PVC below also pins every pod to whichever
|
||||
# node created it (`ai-workstation`, confirmed via the PV's nodeAffinity), so
|
||||
# a single local `ctr image import` of the pushed tar is enough — see
|
||||
# vote-service/README.md for the import command. `imagePullPolicy: Never`
|
||||
# enforces that: no accidental network pull attempt, no ImagePullBackOff.
|
||||
# `ai-for-dummies` did not exist yet as a namespace, so it is created below,
|
||||
# matching the one-namespace-per-app pattern every other small app in this
|
||||
# cluster uses (judge0, minio, pragent, …). No storageClassName set:
|
||||
# microk8s's `hostpath-storage` addon is the default.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ai-for-dummies
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ai-for-dummies-vote
|
||||
namespace: ai-for-dummies
|
||||
labels:
|
||||
app: ai-for-dummies-vote
|
||||
spec:
|
||||
replicas: 1 # single replica: the store is one JSON file on one PVC, not a shared DB
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ai-for-dummies-vote
|
||||
strategy:
|
||||
type: Recreate # avoid two pods writing the same PVC-backed file at once
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ai-for-dummies-vote
|
||||
spec:
|
||||
# Pinned to `kubernets`: the image is imported straight into that node's
|
||||
# containerd store (see README) and `microk8s-hostpath` PVs carry a
|
||||
# nodeAffinity for whichever node first binds them, so scheduling and
|
||||
# storage must agree on one node. `kubernets` is the control-plane node
|
||||
# that hosts the rest of this cluster's workloads.
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: kubernets
|
||||
securityContext:
|
||||
fsGroup: 65532 # matches distroless "nonroot" uid/gid; without it the PVC mounts root-owned and the container can't write votes.json
|
||||
containers:
|
||||
- name: vote-service
|
||||
image: localhost:30892/ai-for-dummies-vote-service:latest
|
||||
imagePullPolicy: Never # image is side-loaded via `ctr image import`; never fetch over the network
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: PORT
|
||||
value: "8080"
|
||||
- name: VOTE_DB_PATH
|
||||
value: /data/votes.json
|
||||
- name: ALLOWED_ORIGIN
|
||||
value: https://netcracker.pages.marcospaulo.dev.br
|
||||
resources:
|
||||
requests: { cpu: 10m, memory: 16Mi }
|
||||
limits: { cpu: 100m, memory: 64Mi }
|
||||
readinessProbe:
|
||||
httpGet: { path: /healthz, port: 8080 }
|
||||
initialDelaySeconds: 2
|
||||
livenessProbe:
|
||||
httpGet: { path: /healthz, port: 8080 }
|
||||
initialDelaySeconds: 5
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: ai-for-dummies-vote-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ai-for-dummies-vote-data
|
||||
namespace: ai-for-dummies
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ai-for-dummies-vote
|
||||
namespace: ai-for-dummies
|
||||
spec:
|
||||
selector:
|
||||
app: ai-for-dummies-vote
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
Reference in New Issue
Block a user