diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index dce4254..e04a266 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -95,9 +95,10 @@ alias fresh="deno run -A -r jsr:@fresh/init" alias dev="bash /workspaces/zig-directory/.devcontainer/run.sh dev" alias prod="bash /workspaces/zig-directory/.devcontainer/run.sh prod" -# Docker shortcuts -alias db="docker build -t dmozdb ." -alias dr="docker run --rm -p 8080:8080 dmozdb" +# Docker shortcuts (split images: backend = Zig, web = Deno/Fresh) +alias db="docker build -f Dockerfile.backend -t dmozdb-backend ." +alias dbw="docker build -f Dockerfile.web -t dmozdb-web ." +alias dr="docker run --rm -p 8080:8080 dmozdb-backend" # Load testing alias loadtest="wrk -t4 -c100 -d30s http://localhost:8080/" @@ -112,4 +113,4 @@ ALIASES echo "" echo "=== Setup complete ===" echo "Run 'zig build' to compile, 'zig build test' to test, 'zig build run' to run." -echo "Run 'db' to docker build, 'dr' to docker run, 'loadtest' for wrk load test." +echo "Run 'db'/'dbw' to docker build backend/web, 'dr' to docker run backend, 'loadtest' for wrk load test." diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index d01146c..743c076 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -5,6 +5,11 @@ on: branches: - main - master + # PRs build + push the images (so a controlled manual cutover can use them) + # but do NOT deploy — the cd job is gated to push events below. + pull_request: + branches: + - main permissions: contents: write @@ -22,7 +27,7 @@ jobs: # backend and runs a Vite build, both of which crawl under QEMU emulation. # The OKE nodes are Ampere A1 (arm64), so this also matches the target arch. runs-on: ubuntu-24.04-arm - name: Build & Push Docker Image + name: Build & Push Docker Images outputs: REPO_PATH: ${{ steps.get-ocir-repository.outputs.repo_path }} steps: @@ -45,20 +50,38 @@ jobs: with: auth_token: ${{ secrets.OCI_AUTH_TOKEN }} - # One combined image: the Zig backend and the Deno/Fresh frontend. The - # OKE nodes are Ampere A1 (arm64), so the build targets linux/arm64. - - name: Build and push + # Two images sharing one OCIR repo, distinguished by tag suffix: the Zig + # backend and the Deno/Fresh frontend now run as separate OKE workloads. + # Both target linux/arm64 (the OKE Ampere A1 nodes); separate gha cache + # scopes keep the two builds from evicting each other. + - name: Build and push backend + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.backend + platforms: linux/arm64 + push: true + tags: "${{ steps.get-ocir-repository.outputs.repo_path }}:${{ github.event.pull_request.head.sha || github.sha }}-backend" + cache-from: type=gha,scope=backend + cache-to: type=gha,mode=max,scope=backend + + - name: Build and push web uses: docker/build-push-action@v6 with: context: . + file: Dockerfile.web platforms: linux/arm64 push: true - tags: "${{ steps.get-ocir-repository.outputs.repo_path }}:${{ github.sha }}" - cache-from: type=gha - cache-to: type=gha,mode=max + tags: "${{ steps.get-ocir-repository.outputs.repo_path }}:${{ github.event.pull_request.head.sha || github.sha }}-web" + cache-from: type=gha,scope=web + cache-to: type=gha,mode=max,scope=web cd: needs: ci + # Deploy only on a real push to main/master — never on a PR build. The first + # split cutover is done manually (the StatefulSet needs a one-time + # --cascade=orphan recreate); after that, push-to-main applies normally. + if: github.event_name == 'push' runs-on: ubuntu-24.04 name: Deploy steps: @@ -94,7 +117,9 @@ jobs: run: | cd deployment/overlays/main - kustomize edit set image dmozdb=$REPO_PATH:$SHA + kustomize edit set image \ + dmozdb-backend=$REPO_PATH:$SHA-backend \ + dmozdb-web=$REPO_PATH:$SHA-web # Record the resolved tag back to the branch — best-effort only. A # re-run of an already-deployed SHA produces no diff (empty commit diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9be036b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,86 +0,0 @@ -# ============================================================ -# dmozdb — single combined image (Zig backend + Deno/Fresh web) -# ============================================================ -# The Zig backend speaks a raw TCP binary protocol and rejects any -# non-loopback connection unless DMOZDB_TRUSTED lists the exact peer -# IPv4 (no CIDR support). Co-locating both processes in one image lets -# the web frontend reach the backend over 127.0.0.1, which the backend -# always trusts — no brittle pod-IP wiring. The entrypoint starts the -# backend on loopback and runs the Deno server in the foreground. -# -# Build: docker build --platform linux/arm64 -t dmozdb . -# Run: docker run -p 8000:8000 dmozdb -# ============================================================ - -# ── Stage 1: build the dmozdb backend ────────────────────── -FROM ubuntu:24.04 AS zig-builder - -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ - curl xz-utils ca-certificates && \ - rm -rf /var/lib/apt/lists/* - -ARG ZIG_VERSION=0.15.2 -RUN ZIG_ARCH="$(uname -m)" && \ - curl -sL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}.tar.xz" \ - | tar -xJ -C /usr/local && \ - ln -s /usr/local/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}/zig /usr/local/bin/zig - -WORKDIR /build -COPY build.zig ./ -COPY src/ src/ -# -Dcpu=baseline: the CI builder and the OKE Ampere A1 nodes are different arm64 -# microarchitectures. A default (native-CPU) build emits instructions the build -# host supports but Ampere may not, crashing the pod with SIGILL. Baseline armv8-a -# runs on every arm64 target. -RUN zig build -Doptimize=ReleaseSafe -Dcpu=baseline - -# ── Stage 2: build the Deno/Fresh web frontend ───────────── -FROM denoland/deno:2.8.1 AS web-builder - -WORKDIR /web - -# Resolve dependencies first so the layer caches across source edits. -# nodeModulesDir is "manual", so deno install populates node_modules from -# the lockfile; vite (run by `deno task build`) needs it present. -COPY web/deno.json web/deno.lock ./ -RUN deno install - -COPY web/ ./ -RUN deno task build - -# ── Stage 3: runtime ─────────────────────────────────────── -FROM denoland/deno:2.8.1 - -WORKDIR /web - -# Backend binary. -COPY --from=zig-builder /build/zig-out/bin/dmozdb /usr/local/bin/dmozdb - -# The built Fresh SSR bundle (_fresh/server.js) is self-contained — vite inlines -# its deps, so node_modules is NOT shipped at runtime (verified: the bundle boots -# and serves under `deno serve --cached-only` with node_modules removed). Ship the -# bundle, the static assets, and the import map / lockfile the entry resolves. -COPY --from=web-builder /web/_fresh ./_fresh -COPY --from=web-builder /web/static ./static -COPY --from=web-builder /web/deno.json /web/deno.lock ./ - -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - -# Backend listens on loopback only — never exposed outside the pod. -ENV DMOZDB_BIND=127.0.0.1 -ENV DMOZDB_PORT=8080 -ENV DMOZDB_DATA_DIR=/var/lib/dmozdb -ENV DMOZDB_CACHE_SIZE_MB=256 -# Frontend reaches the backend over loopback; KV persists to a mounted volume. -ENV DMOZDB_HOST=127.0.0.1 -ENV KV_PATH=/var/lib/web-kv/users.db - -# Only the web frontend is published; the backend stays on 127.0.0.1. -EXPOSE 8000 -VOLUME ["/var/lib/dmozdb", "/var/lib/web-kv"] - -# tini as PID 1: reaps zombies and forwards SIGTERM to the supervisor script, -# which in turn signals dmozdb so it completes its WAL drain / snapshot before -# the pod terminates. /tini ships in the denoland/deno base image. -ENTRYPOINT ["/tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..a59536f --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,55 @@ +# ============================================================ +# dmozdb backend — Zig binary only (no Deno) +# ============================================================ +# Split out of the former combined image so the backend runs as its +# own OKE workload. It listens on the pod network (DMOZDB_BIND set by +# the manifest) and trusts the cluster's pod + node CIDRs via +# DMOZDB_TRUSTED; the Deno frontend reaches it over a ClusterIP service. +# +# Build: docker build -f Dockerfile.backend --platform linux/arm64 -t dmozdb-backend . +# ============================================================ + +# ── Stage 1: build the dmozdb backend ────────────────────── +FROM ubuntu:24.04 AS zig-builder + +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ + curl xz-utils ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +ARG ZIG_VERSION=0.15.2 +RUN ZIG_ARCH="$(uname -m)" && \ + curl -sL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}.tar.xz" \ + | tar -xJ -C /usr/local && \ + ln -s /usr/local/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}/zig /usr/local/bin/zig + +WORKDIR /build +COPY build.zig ./ +COPY src/ src/ +# -Dcpu=baseline: the CI builder and the OKE Ampere A1 nodes are different arm64 +# microarchitectures. A default (native-CPU) build emits instructions the build +# host supports but Ampere may not, crashing the pod with SIGILL. Baseline armv8-a +# runs on every arm64 target. +RUN zig build -Doptimize=ReleaseSafe -Dcpu=baseline + +# ── Stage 2: runtime ─────────────────────────────────────── +FROM ubuntu:24.04 + +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ + tini ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=zig-builder /build/zig-out/bin/dmozdb /usr/local/bin/dmozdb +COPY entrypoint.backend.sh /usr/local/bin/entrypoint.backend.sh +RUN chmod +x /usr/local/bin/entrypoint.backend.sh + +ENV DMOZDB_BIND=0.0.0.0 +ENV DMOZDB_PORT=8080 +ENV DMOZDB_DATA_DIR=/var/lib/dmozdb +ENV DMOZDB_CACHE_SIZE_MB=256 + +EXPOSE 8080 +VOLUME ["/var/lib/dmozdb"] + +# tini as PID 1 reaps zombies and forwards SIGTERM to dmozdb (via the +# exec in the entrypoint), which drains its WAL / snapshots before exit. +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.backend.sh"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..fdf36e1 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,44 @@ +# ============================================================ +# dmozdb web frontend — Deno/Fresh SSR only (no Zig backend) +# ============================================================ +# Split out of the former combined image so the frontend runs as its own +# horizontally-scaled OKE Deployment. It reaches the backend over a +# ClusterIP service (DMOZDB_HOST/DMOZDB_PORT) and stores users/sessions in +# the shared denokv service (KV_PATH + DENO_KV_ACCESS_TOKEN) — both injected +# by the Deployment manifest, not baked here. +# +# Build: docker build -f Dockerfile.web --platform linux/arm64 -t dmozdb-web . +# ============================================================ + +# ── Stage 1: build the Deno/Fresh web frontend ───────────── +FROM denoland/deno:2.8.1 AS web-builder + +WORKDIR /web + +# Resolve dependencies first so the layer caches across source edits. +# nodeModulesDir is "manual", so deno install populates node_modules from +# the lockfile; vite (run by `deno task build`) needs it present. +COPY web/deno.json web/deno.lock ./ +RUN deno install + +COPY web/ ./ +RUN deno task build + +# ── Stage 2: runtime ─────────────────────────────────────── +FROM denoland/deno:2.8.1 + +WORKDIR /web + +# The built Fresh SSR bundle (_fresh/server.js) is self-contained — vite inlines +# its deps, so node_modules is NOT shipped at runtime. Ship the bundle, the +# static assets, and the import map / lockfile the entry resolves. +COPY --from=web-builder /web/_fresh ./_fresh +COPY --from=web-builder /web/static ./static +COPY --from=web-builder /web/deno.json /web/deno.lock ./ + +EXPOSE 8000 + +# tini (shipped in the deno base image at /tini) as PID 1 reaps zombies and +# forwards SIGTERM to the Deno server for a clean shutdown. --unstable-kv is +# required for Deno.openKv against the remote denokv service. +ENTRYPOINT ["/tini", "--", "deno", "serve", "-A", "--unstable-kv", "--port", "8000", "/web/_fresh/server.js"] diff --git a/README.md b/README.md index 03ccd2f..75d7478 100644 --- a/README.md +++ b/README.md @@ -153,16 +153,29 @@ DMOZDB_PORT=8080 DMOZDB_DATA_DIR=./data ./zig-out/bin/dmozdb ### Run with Docker +The backend (Zig) and the web frontend (Deno/Fresh) build as two separate images. + ```bash -# Build the Docker image -docker build -t dmozdb:latest . +# Build the backend image +docker build -f Dockerfile.backend -t dmozdb-backend:latest . -# Run the container +# Run the backend (binary protocol on 8080) docker run -d \ --name dmozdb \ -p 8080:8080 \ -v dmozdb-data:/var/lib/dmozdb \ - dmozdb:latest + dmozdb-backend:latest + +# Build the web frontend image +docker build -f Dockerfile.web -t dmozdb-web:latest . + +# Run the web frontend (point it at the backend + a denokv instance) +docker run -d \ + --name dmozdb-web \ + -p 8000:8000 \ + -e DMOZDB_HOST=host.docker.internal -e DMOZDB_PORT=8080 \ + -e KV_PATH=http://host.docker.internal:4512 -e DENO_KV_ACCESS_TOKEN=dev-token \ + dmozdb-web:latest ``` ### Development Container @@ -180,8 +193,9 @@ The project includes a full [Dev Container](.devcontainer/devcontainer.json) con | `zt` | `zig build test` | Run all tests | | `zr` | `zig build run` | Run the server | | `zfmt` | `zig fmt src/` | Format all source files | -| `db` | `docker build -t dmozdb .` | Build Docker image | -| `dr` | `docker run --rm -p 8080:8080 dmozdb` | Run Docker container | +| `db` | `docker build -f Dockerfile.backend -t dmozdb-backend .` | Build backend image | +| `dbw` | `docker build -f Dockerfile.web -t dmozdb-web .` | Build web image | +| `dr` | `docker run --rm -p 8080:8080 dmozdb-backend` | Run backend container | --- @@ -486,7 +500,8 @@ zig-directory/ ├── .devcontainer/ Dev Container configuration ├── build.zig Zig build configuration ├── bench_binary.zig Binary protocol benchmark tool -└── Dockerfile Alpine-based container image +├── Dockerfile.backend Backend (Zig) container image +└── Dockerfile.web Web frontend (Deno/Fresh) container image ``` --- diff --git a/deployment/base/denokv.yaml b/deployment/base/denokv.yaml new file mode 100644 index 0000000..9bf992d --- /dev/null +++ b/deployment/base/denokv.yaml @@ -0,0 +1,88 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: denokv +spec: + serviceName: denokv + # Single SQLite-backed writer, like dmozdb. The shared users/sessions store + # the multi-replica web frontend reads and writes over KV Connect. + replicas: 1 + selector: + matchLabels: + app: denokv + template: + metadata: + labels: + app: denokv + spec: + enableServiceLinks: false + containers: + - name: denokv + # Pin to a digest/version once confirmed against the cluster. + image: ghcr.io/denoland/denokv:latest + # Global --sqlite-path precedes the `serve` subcommand. The access + # token is read from the secret via env and substituted into args by + # kubelet ($(VAR) expansion); the same token is handed to the web + # frontend as DENO_KV_ACCESS_TOKEN. + command: ["denokv"] + args: + - "--sqlite-path" + - "/data/denokv.sqlite" + - "serve" + - "--access-token" + - "$(DENO_KV_ACCESS_TOKEN)" + env: + - name: DENO_KV_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: denokv-auth + key: token + ports: + - containerPort: 4512 + name: kv + volumeMounts: + - name: kv-data + mountPath: /data + # KV Connect is HTTP, but a bare TCP connect is enough to know the + # server is accepting; a dead process stops listening on 4512. + livenessProbe: + tcpSocket: + port: 4512 + periodSeconds: 15 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: 4512 + periodSeconds: 10 + failureThreshold: 3 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" + volumeClaimTemplates: + - metadata: + name: kv-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: denokv + labels: + app: denokv +spec: + type: ClusterIP + selector: + app: denokv + ports: + - port: 4512 + targetPort: 4512 + protocol: TCP + name: kv diff --git a/deployment/base/ingress.yaml b/deployment/base/ingress.yaml index 152a610..f7d6ac1 100644 --- a/deployment/base/ingress.yaml +++ b/deployment/base/ingress.yaml @@ -11,6 +11,6 @@ spec: path: / backend: service: - name: dmozdb + name: web port: number: 8000 diff --git a/deployment/base/kustomization.yaml b/deployment/base/kustomization.yaml index 641fff2..eb5d82b 100644 --- a/deployment/base/kustomization.yaml +++ b/deployment/base/kustomization.yaml @@ -4,7 +4,10 @@ kind: Kustomization resources: - namespace.yaml - statefulset.yaml + - denokv.yaml + - web.yaml - service.yaml - ingress.yaml + - networkpolicy.yaml namespace: dmozdb diff --git a/deployment/base/networkpolicy.yaml b/deployment/base/networkpolicy.yaml new file mode 100644 index 0000000..cc271c7 --- /dev/null +++ b/deployment/base/networkpolicy.yaml @@ -0,0 +1,44 @@ +# NOTE: the cluster's CNI is flannel, which does NOT enforce NetworkPolicy — +# these are declarative intent that only takes effect if Calico (or another +# policy-enforcing CNI) is added. Today the real in-cluster boundary is the +# ClusterIP services (not externally exposed) plus the backend's CIDR trust. +# The node-subnet ipBlock keeps kubelet tcpSocket probes working once enforced. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: dmozdb-allow-web +spec: + podSelector: + matchLabels: + app: dmozdb + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app: web + - ipBlock: + cidr: 10.0.1.0/24 + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: denokv-allow-web +spec: + podSelector: + matchLabels: + app: denokv + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app: web + - ipBlock: + cidr: 10.0.1.0/24 + ports: + - protocol: TCP + port: 4512 diff --git a/deployment/base/service.yaml b/deployment/base/service.yaml index 6b1a78f..90703b1 100644 --- a/deployment/base/service.yaml +++ b/deployment/base/service.yaml @@ -5,13 +5,15 @@ metadata: labels: app: dmozdb spec: + # Backend protocol endpoint. Reachable only in-cluster (ClusterIP); the web + # frontend connects here, and the Zig backend's CIDR trust + this service's + # non-exposure are the access boundary. The web frontend's own Service lives + # in web.yaml; denokv's in denokv.yaml. type: ClusterIP selector: app: dmozdb ports: - # Only the web frontend is exposed; the backend stays on pod-local - # loopback and has no Service. - - port: 8000 - targetPort: 8000 + - port: 8080 + targetPort: 8080 protocol: TCP - name: http + name: dmozdb diff --git a/deployment/base/statefulset.yaml b/deployment/base/statefulset.yaml index db2cf52..93cf179 100644 --- a/deployment/base/statefulset.yaml +++ b/deployment/base/statefulset.yaml @@ -15,25 +15,28 @@ spec: labels: app: dmozdb spec: - # Kubernetes injects legacy service-link env vars for every Service in the - # namespace; the Service named "dmozdb" would set DMOZDB_PORT=tcp://:8000, - # clobbering the backend's own DMOZDB_PORT and breaking both processes. The - # app talks over loopback and uses no service-link vars, so disable them. + # The "dmozdb" Service would inject DMOZDB_PORT=tcp://:8080 as a + # legacy service-link env var, clobbering the backend's own DMOZDB_PORT. + # The app needs no service-link vars, so disable them. enableServiceLinks: false containers: - name: dmozdb - image: dmozdb:latest + image: dmozdb-backend:latest ports: - - containerPort: 8000 - name: http - # The frontend reaches the loopback backend via these; set explicitly - # (not just in the image ENV) so they are authoritative in the pod. - # DMOZDB_BIND=127.0.0.1 and the data paths stay fixed in the image ENV. + - containerPort: 8080 + name: dmozdb + # The frontend now runs in a separate pod and reaches the backend over + # the cluster network, so the backend binds 0.0.0.0 and trusts the + # flannel pod CIDR (10.244.0.0/16) plus the node subnet (10.0.1.0/24, + # the source of kubelet tcpSocket probes). Without the node subnet the + # startup/liveness probe connection is rejected at accept. env: - - name: DMOZDB_HOST - value: "127.0.0.1" + - name: DMOZDB_BIND + value: "0.0.0.0" - name: DMOZDB_PORT value: "8080" + - name: DMOZDB_TRUSTED + value: "10.244.0.0/16,10.0.1.0/24" - name: DMOZDB_CACHE_SIZE_MB value: "256" - name: DMOZDB_THREAD_COUNT @@ -41,27 +44,22 @@ spec: volumeMounts: - name: data mountPath: /var/lib/dmozdb - - name: web-kv - mountPath: /var/lib/web-kv - # All probes hit the web frontend's /healthz, which also TCP-checks - # the loopback backend — so a dead backend fails liveness and - # restarts the whole pod (the entrypoint relaunches both). + # The backend speaks a raw binary protocol, not HTTP, so probes use a + # bare TCP connect. A dead backend stops accepting on 8080 → the probe + # fails → the pod restarts. startupProbe: - httpGet: - path: /healthz - port: 8000 + tcpSocket: + port: 8080 periodSeconds: 3 failureThreshold: 30 livenessProbe: - httpGet: - path: /healthz - port: 8000 + tcpSocket: + port: 8080 periodSeconds: 15 failureThreshold: 3 readinessProbe: - httpGet: - path: /healthz - port: 8000 + tcpSocket: + port: 8080 periodSeconds: 10 failureThreshold: 3 resources: @@ -69,8 +67,10 @@ spec: memory: "512Mi" cpu: "250m" limits: + # Raised from the combined image's 1000m now that the CPU-heavy + # Deno SSR runs in its own pod and no longer shares this budget. memory: "2Gi" - cpu: "1000m" + cpu: "1500m" volumeClaimTemplates: - metadata: name: data @@ -79,10 +79,3 @@ spec: resources: requests: storage: 10Gi - - metadata: - name: web-kv - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 1Gi diff --git a/deployment/base/web.yaml b/deployment/base/web.yaml new file mode 100644 index 0000000..8001b5f --- /dev/null +++ b/deployment/base/web.yaml @@ -0,0 +1,114 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + # Stateless SSR; the HPA owns the replica count (min 2). 2 here is just the + # pre-HPA starting point. + replicas: 2 + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + # The "dmozdb" Service would inject DMOZDB_PORT=tcp://:8080 as a + # service-link env var; the frontend sets DMOZDB_PORT itself, so disable + # the legacy links to avoid the clobber. + enableServiceLinks: false + # Spread replicas across both nodes when possible so a node loss doesn't + # take the whole frontend down. + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: web + topologyKey: kubernetes.io/hostname + containers: + - name: web + image: dmozdb-web:latest + ports: + - containerPort: 8000 + name: http + env: + - name: DMOZDB_HOST + value: "dmozdb.dmozdb.svc.cluster.local" + - name: DMOZDB_PORT + value: "8080" + - name: KV_PATH + value: "http://denokv.dmozdb.svc.cluster.local:4512" + - name: DENO_KV_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: denokv-auth + key: token + # /healthz returns 200 only when the backend is reachable, so it gates + # startup and readiness (don't serve traffic without a backend). For + # liveness we only check the web process itself — a backend blip must + # not trigger a restart storm of the frontend. + startupProbe: + httpGet: + path: /healthz + port: 8000 + periodSeconds: 3 + failureThreshold: 20 + readinessProbe: + httpGet: + path: /healthz + port: 8000 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + tcpSocket: + port: 8000 + periodSeconds: 15 + failureThreshold: 3 + resources: + requests: + memory: "256Mi" + cpu: "200m" + limits: + memory: "512Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: web + labels: + app: web +spec: + type: ClusterIP + selector: + app: web + ports: + - port: 8000 + targetPort: 8000 + protocol: TCP + name: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: web +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: web + minReplicas: 2 + # Ceiling on the current free-tier cluster (~2100m free after ticketlist→1): + # 4 × 200m requests fits with headroom for denokv + dmozdb burst. + maxReplicas: 4 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 diff --git a/deployment/metrics-server/kustomization.yaml b/deployment/metrics-server/kustomization.yaml new file mode 100644 index 0000000..f220712 --- /dev/null +++ b/deployment/metrics-server/kustomization.yaml @@ -0,0 +1,24 @@ +# Standalone install of metrics-server into kube-system — the prerequisite for +# the web HPA. Kept OUT of the dmozdb overlay so its `namespace: dmozdb` +# transform doesn't rewrite metrics-server into the wrong namespace. +# +# Apply with: kubectl apply -k deployment/metrics-server +# +# --kubelet-insecure-tls is required on OKE: the kubelet serving certs aren't +# signed by a CA metrics-server trusts, so the secure handshake otherwise fails +# and every node reports "no metrics". +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.7.2/components.yaml + +patches: + - target: + kind: Deployment + name: metrics-server + namespace: kube-system + patch: |- + - op: add + path: /spec/template/spec/containers/0/args/- + value: --kubelet-insecure-tls diff --git a/deployment/overlays/main/ingress.yaml b/deployment/overlays/main/ingress.yaml index 58d8dd7..107f365 100644 --- a/deployment/overlays/main/ingress.yaml +++ b/deployment/overlays/main/ingress.yaml @@ -18,6 +18,6 @@ spec: path: / backend: service: - name: dmozdb + name: web port: number: 8000 diff --git a/deployment/overlays/main/kustomization.yaml b/deployment/overlays/main/kustomization.yaml index 6fa5ebb..ed451f6 100644 --- a/deployment/overlays/main/kustomization.yaml +++ b/deployment/overlays/main/kustomization.yaml @@ -8,11 +8,16 @@ namespace: dmozdb patches: - path: statefulset.yaml +- path: web.yaml - path: ingress.yaml -# The CD pipeline rewrites newTag to the built image's commit SHA via -# `kustomize edit set image dmozdb=:` before applying. +# The CD pipeline rewrites both newTags to the built commit SHA via +# `kustomize edit set image dmozdb-backend=:-backend dmozdb-web=:-web` +# before applying. Both images share the one OCIR repo, distinguished by tag suffix. images: -- name: dmozdb +- name: dmozdb-backend newName: lhr.ocir.io/lrhvckxzwf3l/zig-directory - newTag: 7c9f389007d9fd9e3eb8082102e92bffcfc8e87e + newTag: 7c9f389007d9fd9e3eb8082102e92bffcfc8e87e-backend +- name: dmozdb-web + newName: lhr.ocir.io/lrhvckxzwf3l/zig-directory + newTag: 7c9f389007d9fd9e3eb8082102e92bffcfc8e87e-web diff --git a/deployment/overlays/main/web.yaml b/deployment/overlays/main/web.yaml new file mode 100644 index 0000000..28a5ea0 --- /dev/null +++ b/deployment/overlays/main/web.yaml @@ -0,0 +1,12 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + template: + spec: + containers: + - name: web + imagePullPolicy: Always + imagePullSecrets: + - name: ocirsecret diff --git a/entrypoint.backend.sh b/entrypoint.backend.sh new file mode 100644 index 0000000..1063685 --- /dev/null +++ b/entrypoint.backend.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Launches the dmozdb backend as PID 1's child under tini. `exec` replaces +# this shell with dmozdb so tini forwards SIGTERM straight to it, letting its +# own signal handler drain the WAL / write a snapshot before the pod dies. +set -uo pipefail + +# The data dir is a mounted PVC; ensure it exists before dmozdb opens it. +mkdir -p "${DMOZDB_DATA_DIR:-/var/lib/dmozdb}" + +echo "[entrypoint] starting dmozdb on ${DMOZDB_BIND:-0.0.0.0}:${DMOZDB_PORT:-8080} (trusted=${DMOZDB_TRUSTED:-loopback-only})" +exec dmozdb diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index c6bf683..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Supervises the two colocated processes (dmozdb backend + Deno/Fresh web) and, -# on pod termination, signals both and waits for dmozdb to finish its WAL drain -# / snapshot. Run under tini (see Dockerfile ENTRYPOINT) which forwards SIGTERM -# here. No `set -e`: with background jobs and a signal trap, an aborting shell -# would skip the graceful drain. -set -uo pipefail - -# Persisted paths live on mounted volumes; ensure they exist before either -# process opens them (an empty PVC mount may not pre-create nested dirs). -mkdir -p "${DMOZDB_DATA_DIR:-/var/lib/dmozdb}" -mkdir -p "$(dirname "${KV_PATH:-/web/data/users.db}")" - -# Backend bound to loopback (DMOZDB_BIND=127.0.0.1 from the image env); the -# frontend reaches it over 127.0.0.1, which the backend always trusts. -echo "[entrypoint] starting dmozdb backend on 127.0.0.1:${DMOZDB_PORT:-8080}..." -dmozdb & -DMOZDB_PID=$! - -echo "[entrypoint] starting web frontend on 0.0.0.0:8000..." -deno serve -A --unstable-kv /web/_fresh/server.js & -WEB_PID=$! - -shutdown() { - echo "[entrypoint] signal received; stopping children..." - kill -TERM "$WEB_PID" "$DMOZDB_PID" 2>/dev/null || true -} -trap shutdown TERM INT - -# Wake when either child exits (a crash) or a signal arrives, then stop the -# other and block until both have exited — letting dmozdb drain cleanly. -wait -n -echo "[entrypoint] a process exited; draining the other..." -kill -TERM "$WEB_PID" "$DMOZDB_PID" 2>/dev/null || true -wait -echo "[entrypoint] all processes stopped." diff --git a/scripts/migrate-kv.ts b/scripts/migrate-kv.ts new file mode 100644 index 0000000..b29b78a --- /dev/null +++ b/scripts/migrate-kv.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env -S deno run -A --unstable-kv + +// One-shot migration of the file-backed Deno KV (users + sessions) into the +// shared denokv service. Users are copied; sessions are intentionally dropped +// (the 7-day session cookies simply re-authenticate), so any logged-in user +// signs in once more after cutover. +// +// Usage: +// DENO_KV_ACCESS_TOKEN= \ +// deno run -A --unstable-kv scripts/migrate-kv.ts +// +// dest-kv-url examples: +// http://denokv.dmozdb.svc.cluster.local:4512 (run in-cluster) +// http://localhost:4512 (via `kubectl port-forward svc/denokv 4512`) +// +// The source SQLite file is in WAL mode; copy its -wal/-shm sidecars too, or +// run against a quiesced source, so recent writes aren't missed. + +const sourcePath = Deno.args[0] ?? Deno.env.get("SOURCE_KV_PATH"); +const destUrl = Deno.args[1] ?? Deno.env.get("DEST_KV_URL"); + +if (!sourcePath || !destUrl) { + console.error( + "usage: migrate-kv.ts (DENO_KV_ACCESS_TOKEN required for dest)", + ); + Deno.exit(1); +} + +const src = await Deno.openKv(sourcePath); +const dest = await Deno.openKv(destUrl); + +let migrated = 0; +let skippedSessions = 0; + +for await (const entry of src.list({ prefix: [] })) { + if (entry.key[0] === "sessions") { + skippedSessions++; + continue; + } + await dest.set(entry.key, entry.value); + migrated++; +} + +src.close(); +dest.close(); + +console.log( + `migrated ${migrated} entries; skipped ${skippedSessions} sessions (will re-auth)`, +); diff --git a/src/main.zig b/src/main.zig index ce1594a..55c3e17 100644 --- a/src/main.zig +++ b/src/main.zig @@ -27,6 +27,8 @@ const EpollServer = epoll_server.EpollServer; const log = std.log.scoped(.dmozdb); +const Cidr = struct { network: u32, prefix: u6 }; + pub const Config = struct { port: u16 = 8080, data_dir: []const u8 = "/var/lib/dmozdb", @@ -43,6 +45,8 @@ pub const Config = struct { bind_address: [4]u8 = .{ 127, 0, 0, 1 }, trusted_ips: [MAX_TRUSTED_IPS][4]u8 = undefined, trusted_count: u8 = 0, + trusted_cidrs: [MAX_TRUSTED_IPS]Cidr = undefined, + trusted_cidr_count: u8 = 0, const MAX_TRUSTED_IPS = 16; @@ -92,7 +96,8 @@ pub const Config = struct { } fn parseTrustedIps(self: *Config, raw: []const u8) void { - var count: u8 = 0; + var ip_count: u8 = 0; + var cidr_count: u8 = 0; var rest = raw; while (rest.len > 0) { var end: usize = 0; @@ -101,18 +106,33 @@ pub const Config = struct { rest = if (end < rest.len) rest[end + 1 ..] else &.{}; if (token.len == 0) continue; - if (count >= MAX_TRUSTED_IPS) { - log.warn("DMOZDB_TRUSTED: dropping '{s}' — exceeds MAX_TRUSTED_IPS={d}", .{ token, MAX_TRUSTED_IPS }); - continue; - } - if (parseIpv4(token)) |ip| { - self.trusted_ips[count] = ip; - count += 1; + + if (std.mem.indexOfScalar(u8, token, '/') != null) { + if (cidr_count >= MAX_TRUSTED_IPS) { + log.warn("DMOZDB_TRUSTED: dropping CIDR '{s}' — exceeds MAX_TRUSTED_IPS={d}", .{ token, MAX_TRUSTED_IPS }); + continue; + } + if (parseCidr(token)) |cidr| { + self.trusted_cidrs[cidr_count] = cidr; + cidr_count += 1; + } else { + log.warn("DMOZDB_TRUSTED: ignoring unparseable CIDR token '{s}'", .{token}); + } } else { - log.warn("DMOZDB_TRUSTED: ignoring unparseable IPv4 token '{s}'", .{token}); + if (ip_count >= MAX_TRUSTED_IPS) { + log.warn("DMOZDB_TRUSTED: dropping '{s}' — exceeds MAX_TRUSTED_IPS={d}", .{ token, MAX_TRUSTED_IPS }); + continue; + } + if (parseIpv4(token)) |ip| { + self.trusted_ips[ip_count] = ip; + ip_count += 1; + } else { + log.warn("DMOZDB_TRUSTED: ignoring unparseable IPv4 token '{s}'", .{token}); + } } } - self.trusted_count = count; + self.trusted_count = ip_count; + self.trusted_cidr_count = cidr_count; } pub fn isAllowed(self: *const Config, addr: [4]u8) bool { @@ -120,14 +140,36 @@ pub const Config = struct { for (self.trusted_ips[0..self.trusted_count]) |trusted| { if (std.mem.eql(u8, &addr, &trusted)) return true; } + const addr_u32 = octetsToU32(addr); + for (self.trusted_cidrs[0..self.trusted_cidr_count]) |cidr| { + if ((addr_u32 & maskU32(cidr.prefix)) == cidr.network) return true; + } return false; } pub fn isProtectedMode(self: *const Config) bool { - return self.bind_address[0] != 127 and self.trusted_count == 0; + return self.bind_address[0] != 127 and self.trusted_count == 0 and self.trusted_cidr_count == 0; } }; +fn octetsToU32(a: [4]u8) u32 { + return (@as(u32, a[0]) << 24) | (@as(u32, a[1]) << 16) | (@as(u32, a[2]) << 8) | @as(u32, a[3]); +} + +fn maskU32(prefix: u6) u32 { + if (prefix == 0) return 0; + const shift: u5 = @intCast(32 - @as(u8, prefix)); + return @as(u32, 0xFFFFFFFF) << shift; +} + +fn parseCidr(s: []const u8) ?Cidr { + const slash = std.mem.indexOfScalar(u8, s, '/') orelse return null; + const ip = parseIpv4(s[0..slash]) orelse return null; + const prefix = std.fmt.parseInt(u6, s[slash + 1 ..], 10) catch return null; + if (prefix > 32) return null; + return Cidr{ .network = octetsToU32(ip) & maskU32(prefix), .prefix = prefix }; +} + fn parseIpv4(s: []const u8) ?[4]u8 { var octets: [4]u8 = undefined; var idx: u8 = 0; @@ -316,6 +358,43 @@ test "Config.parseTrustedIps" { try std.testing.expectEqual([4]u8{ 172, 16, 0, 1 }, config.trusted_ips[2]); } +test "Config.parseTrustedIps separates CIDRs from exact IPs" { + var config = Config{}; + config.parseTrustedIps("10.244.0.0/16, 10.0.1.0/24, 192.168.1.5"); + try std.testing.expectEqual(@as(u8, 1), config.trusted_count); + try std.testing.expectEqual(@as(u8, 2), config.trusted_cidr_count); + try std.testing.expectEqual([4]u8{ 192, 168, 1, 5 }, config.trusted_ips[0]); +} + +test "isAllowed accepts an address inside a trusted CIDR" { + var config = Config{}; + config.parseTrustedIps("10.244.0.0/16,10.0.1.0/24"); + try std.testing.expect(config.isAllowed(.{ 10, 244, 2, 14 })); + try std.testing.expect(config.isAllowed(.{ 10, 244, 1, 200 })); + try std.testing.expect(config.isAllowed(.{ 10, 0, 1, 53 })); +} + +test "isAllowed rejects an address outside every trusted CIDR" { + var config = Config{}; + config.parseTrustedIps("10.244.0.0/16,10.0.1.0/24"); + try std.testing.expect(!config.isAllowed(.{ 10, 245, 0, 1 })); + try std.testing.expect(!config.isAllowed(.{ 10, 0, 2, 1 })); + try std.testing.expect(!config.isAllowed(.{ 192, 168, 1, 1 })); +} + +test "isProtectedMode is false when only a CIDR is trusted" { + var config = Config{}; + config.bind_address = .{ 0, 0, 0, 0 }; + config.parseTrustedIps("10.244.0.0/16"); + try std.testing.expect(!config.isProtectedMode()); +} + +test "parseCidr rejects malformed tokens" { + try std.testing.expect(parseCidr("10.0.0.0/33") == null); + try std.testing.expect(parseCidr("10.0.0.0/abc") == null); + try std.testing.expect(parseCidr("not-an-ip/24") == null); +} + test "Config: rename_inline_threshold default and env override" { const default_cfg = Config{}; try std.testing.expectEqual(@as(u32, 5000), default_cfg.rename_inline_threshold);