Skip to main content

Ironscan

Scan, persist, browse: Ironman race data as a queryable archive.

Ironscan is a first-party project: a read-only Go client for Ironman race data plus the small web stack that runs it. The iron-web binary scrapes ironman.com, RTRT.me and Open-Meteo, persists calendars, participants, results, splits and course assets to Postgres, and serves an embedded SvelteKit UI over Connect-RPC from one port. A companion CLI (ironctl) runs the same scrapes from a terminal and can write into the same database. The bulk of the data is the full-field split tables, which is what sizes the deployment.

Installation​

Architecture​

  • Deployment: Single ironscan deployment in the ironscan namespace, one replica, Recreate strategy
  • Image: gitea.web.kueber.eu/johnny/ironscan, a static Go binary with the SvelteKit UI embedded; the repo's own Gitea workflow builds and pushes it to the private registry, and the secrets.docker-gitea namespace label delivers the regcred-docker-gitea pull secret
  • Database: CNPG PostgreSQL cluster from the cnpg component, two instances on longhorn-db-single-encrypted, volume patched up to 30Gi because the split imports outgrew the component default; the DSN reaches the pod as DATABASE_URL from the cnpg-app secret
  • Storage: No PVC of its own; the only writable path is an emptyDir on /tmp
  • Networking: ClusterIP service on port 8080, HTTPRoute on the public gateway's https listener for ironscan.web.kueber.eu; CORS_ORIGIN names the same URL
  • Probes: Goose migrations run before the listener opens, so the startup probe on /healthz allows ten minutes (the app's own MIGRATION_TIMEOUT) before readiness (/readyz) and liveness take over
  • Poll-lease guard: LIVE_INSTANCE_ID is set from the pod name, so a second instance (a debug run, an accidental scale-up) never double-polls the same live RTRT event
  • Priority: tier-2 for the app and its database, with requests of 25m/64Mi (app) and 50m/256Mi (Postgres) and memory limits at roughly twice that

Security​

  • Runs as uid 65532, runAsNonRoot: true, read-only root filesystem, all capabilities dropped, RuntimeDefault seccomp, no service account token
  • Login happens at the gateway, not in the app: an Envoy Gateway SecurityPolicy (oidc-ironscan) fronts the HTTPRoute with Keycloak, client ironscan in the kueber.eu realm, callback /oauth2/callback, Lax cookies scoped to the hostname. It gates every path, /healthz included; kubelet probes hit the pod directly and are unaffected. The app's own bearer-token guard stays off because the SvelteKit frontend never attaches an Authorization header
  • Envoy reaches the issuer through a dedicated Backend (keycloak-backend) validated against the proxy image's system CA bundle, because the in-cluster DNS view of keycloak.web.kueber.eu points back at the gateway. The authorization, token and end-session endpoints are declared explicitly so the controller skips OIDC discovery, which fails with a system-trust Backend on the Envoy Gateway versions in use; the end-session endpoint is what makes RP-initiated logout work with discovery off. The pattern is written up in identity end-to-end
  • Network contract from the app-network-policy component: ingress only from the public gateway's Envoy pods; the baseline egress (DNS, the namespace, the gateway) is widened by egress-ironscan to 443/80 on the internet so the scanners can reach ironman.com, RTRT.me and Open-Meteo
  • Database volumes encrypted at rest with the SOPS-managed cnpg-longhorn key; first-party images from the Gitea registry fall under the audit-mode cosign policy described in image supply chain

Updates​

The deployment pins latest@sha256:… and pulls with imagePullPolicy: Always; Renovate moves the digest whenever the Gitea workflow pushes a new build. Schema changes ship as goose migrations inside the binary and run on the next start, within the startup-probe window.

Data Management​

  • Database: CNPG cluster cnpg, 30Gi, dumped by the backups-cnpg PreBackupPod (pg_isready wait, then pg_dump --clean of app)
  • Backups: k8up Schedules to Hetzner and Backblaze (workload-talos-ironscan-*) via restic from the backups component: daily backup, weekly check and prune, retention of 5 last, 7 daily, 4 weekly, 12 monthly and 5 yearly snapshots
  • Provisioning: the workspace component creates the restic buckets and the DNS record for the hostname through a TofuWorkspace
  • Restoring means replaying the dump into a fresh cluster; see Postgres restore

User Management​

Whoever Keycloak lets through the ironscan client is in; the app has no accounts, roles or per-user state. The confidential client is created by the app's workspace (spec.oidc, not gated) and the policy reads the minted secret from workspace-outputs; without that Secret the route answers with errors before the UI is ever reached.

Configuration Management​

  • Runtime settings as plain env vars in the talos patch (CORS_ORIGIN, LIVE_INSTANCE_ID); the database DSN from the CNPG-generated cnpg-app secret
  • The OIDC client secret in workspace-outputs (minted by the workspace), the Longhorn volume key in cnpg-longhorn, the restic password in k8up-restic-password, the tofu state key in tofu-encryption

Usage​

Sign in through Keycloak and browse races by calendar, then drill into participants, results and splits; a running-scans popup shows scan progress when the backend is scanning. New data comes from the in-pod scanners; bulk history can still be loaded through the database (ironctl --db, or a dump restore).

Cluster-specific deviations from the above live in the per-cluster README — see k8s/apps/ironscan/talos/README.md.

Cluster Deployment​

Ironscan — Talos cluster

Deviations from defaults​

  • First-party image from the private Gitea registry — gitea.web.kueber.eu/johnny/ironscan:latest, pulled via regcred-docker-gitea (namespace label secrets.docker-gitea). The ironscan repo builds and pushes the image itself (docker-build workflow).
  • OIDC at the gateway, not in the app — iron-web's own AUTH_TOKEN bearer guard stays off because the SvelteKit frontend never attaches an Authorization header. Instead an Envoy Gateway SecurityPolicy (security-policy-oidc.yaml) fronts the HTTPRoute with Keycloak (client ironscan, realm kueber.eu, redirect https://ironscan.web.kueber.eu/oauth2/callback). Envoy reaches the issuer through a dedicated Backend validated against the proxy image's system CA bundle (wellKnownCACertificates: System), since the in-cluster DNS view of keycloak.web.kueber.eu points back at the gateway — no manifest updates when Let's Encrypt rotates chains. The Keycloak client is created by the TofuWorkspace (workspace/patch-oidc.yaml, not gated) and the policy reads its secret from workspace-outputs; nothing is copied by hand. Note this gates every path — including /healthz from outside; kubelet probes hit the pod directly and are unaffected.
  • 30Gi CNPG volume — full-field split imports dominate: the June 2026 dump was 1.3 GiB gzipped, roughly 10 GiB live with indexes, so the component default of 2Gi is patched up in cnpg/patch-cluster.yaml.
  • 10-minute startup probe — goose migrations run before the listener opens (MIGRATION_TIMEOUT defaults to 10m, sized for one-shot data migrations over the splits table), so the startup probe covers the same window before liveness takes over.
  • Single replica with the poll-lease guard armed — LIVE_INSTANCE_ID is set from the pod name anyway; it costs nothing and prevents double-polling of live RTRT events if a second instance ever runs alongside (debug run, accidental scale-up).
Kubernetes Metadata
  • Image: gitea.web.kueber.eu/johnny/ironscan:latest@sha256:c34460fffc7726abad97f61a4c6fdd43ef0c0a2ecf060c9efb00e386e84dce10
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/name: ironscan
name: ironscan
namespace: ironscan
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: ironscan
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: ironscan
spec:
automountServiceAccountToken: false
containers:
- env:
- name: CORS_ORIGIN
value: https://ironscan.web.kueber.eu
- name: LIVE_INSTANCE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: DATABASE_URL
valueFrom:
secretKeyRef:
key: uri
name: cnpg-app
image: gitea.web.kueber.eu/johnny/ironscan:latest@sha256:c34460fffc7726abad97f61a4c6fdd43ef0c0a2ecf060c9efb00e386e84dce10
imagePullPolicy: Always
livenessProbe:
failureThreshold: 5
httpGet:
path: /healthz
port: web
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 3
name: ironscan
ports:
- containerPort: 8080
name: web
protocol: TCP
readinessProbe:
failureThreshold: 3
httpGet:
path: /readyz
port: web
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 3
resources:
limits:
memory: 512Mi
requests:
cpu: 25m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
startupProbe:
failureThreshold: 60
httpGet:
path: /healthz
port: web
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- mountPath: /tmp
name: tmp
imagePullSecrets:
- name: regcred-docker-gitea
priorityClassName: tier-2
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: tmp