From 52e0f5e37561770316a23894ef21790e1d6cb1fd Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Wed, 10 Jun 2026 07:53:55 +0200 Subject: [PATCH] feat(operator): production build + k3s deployment - Dockerfile for the operator app (same pattern as portal/booking). - Env-driven auth/app base URLs in nuxt.config so one build serves dev (.local) and production (.eu). - Deployment + Service + Ingress on operator.dezky.eu. - Add operator to the typecheck matrix. --- .github/workflows/ci.yml | 1 + apps/operator/Dockerfile | 24 ++++ apps/operator/nuxt.config.ts | 20 +++- .../production/fleet/apps/operator.yaml | 110 ++++++++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 apps/operator/Dockerfile create mode 100644 infrastructure/production/fleet/apps/operator.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cda0e8..10dc671 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: - { name: portal, dir: apps/portal } - { name: booking, dir: apps/booking } - { name: website, dir: apps/website } + - { name: operator, dir: apps/operator } defaults: run: working-directory: ${{ matrix.target.dir }} diff --git a/apps/operator/Dockerfile b/apps/operator/Dockerfile new file mode 100644 index 0000000..28f4a4d --- /dev/null +++ b/apps/operator/Dockerfile @@ -0,0 +1,24 @@ +# Production image for the dezky operator portal (Nuxt SSR). +# Build context = this directory (apps/operator). +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS build +WORKDIR /app +RUN corepack enable +# Install deps first for layer caching (pnpm version comes from packageManager). +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV NUXT_PUBLIC_OPERATOR_URL=https://operator.dezky.eu +# OIDC, NUXT_PUBLIC_AUTH_URL and PLATFORM_API_INTERNAL_URL are injected at +# deploy time (see infrastructure/production/fleet/README.md). +COPY --from=build /app/.output ./.output +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] diff --git a/apps/operator/nuxt.config.ts b/apps/operator/nuxt.config.ts index d0dad09..bb4b05c 100644 --- a/apps/operator/nuxt.config.ts +++ b/apps/operator/nuxt.config.ts @@ -2,6 +2,14 @@ // Separate app from apps/portal — different OAuth client, different cookies, // different domain, stricter authorization. See docs/OPERATOR-PLAN.md. +// Base URLs are environment-driven so one build runs in dev (.local) and +// production (.eu) — same approach as apps/portal. Set at BUILD (CI) and +// RUNTIME (fleet/apps/operator.yaml + operator-secrets); the .local defaults +// keep local dev working with no env. +const AUTH_URL = (process.env.NUXT_PUBLIC_AUTH_URL || 'https://auth.dezky.local').replace(/\/$/, '') +const OPERATOR_URL = (process.env.NUXT_PUBLIC_OPERATOR_URL || 'https://operator.dezky.local').replace(/\/$/, '') +const OPERATOR_OIDC_APP_SLUG = process.env.OPERATOR_OIDC_APP_SLUG || 'dezky-operator' + export default defineNuxtConfig({ compatibilityDate: '2026-01-01', devtools: { enabled: true }, @@ -52,13 +60,13 @@ export default defineNuxtConfig({ oidc: { clientId: process.env.NUXT_OIDC_CLIENT_ID || '', clientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '', - redirectUri: process.env.NUXT_OIDC_REDIRECT_URI || '', - authorizationUrl: 'https://auth.dezky.local/application/o/authorize/', - tokenUrl: 'https://auth.dezky.local/application/o/token/', - userInfoUrl: 'https://auth.dezky.local/application/o/userinfo/', - logoutUrl: 'https://auth.dezky.local/application/o/dezky-operator/end-session/', + redirectUri: process.env.NUXT_OIDC_REDIRECT_URI || `${OPERATOR_URL}/auth/oidc/callback`, + authorizationUrl: `${AUTH_URL}/application/o/authorize/`, + tokenUrl: `${AUTH_URL}/application/o/token/`, + userInfoUrl: `${AUTH_URL}/application/o/userinfo/`, + logoutUrl: `${AUTH_URL}/application/o/${OPERATOR_OIDC_APP_SLUG}/end-session/`, openIdConfiguration: - 'https://auth.dezky.local/application/o/dezky-operator/.well-known/openid-configuration', + `${AUTH_URL}/application/o/${OPERATOR_OIDC_APP_SLUG}/.well-known/openid-configuration`, scope: ['openid', 'profile', 'email', 'groups'], userNameClaim: 'preferred_username', responseType: 'code', diff --git a/infrastructure/production/fleet/apps/operator.yaml b/infrastructure/production/fleet/apps/operator.yaml new file mode 100644 index 0000000..8028e93 --- /dev/null +++ b/infrastructure/production/fleet/apps/operator.yaml @@ -0,0 +1,110 @@ +# operator — Nuxt SSR internal control plane on operator.dezky.eu. Platform +# admins only (Authentik dezky-operator app is gated by the +# dezky-platform-admins group policy). Talks to platform-api over the cluster +# network via its server routes (PLATFORM_API_INTERNAL_URL), forwarding the +# signed-in operator's access token. Authenticates through Authentik (OIDC), +# using a DIFFERENT client (dezky-operator) than the customer portal. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: operator + namespace: dezky-apps + labels: + app.kubernetes.io/name: operator + app.kubernetes.io/part-of: dezky +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: operator + template: + metadata: + labels: + app.kubernetes.io/name: operator + spec: + containers: + - name: operator + # CI pins this to the commit SHA at deploy time (kustomize edit set image + # in .gitea/workflows/ci.yml); :latest here is the fallback. + image: git.lastcloud.io/ronnibaslund/dezky/operator:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "3000" + - name: NUXT_PUBLIC_OPERATOR_URL + value: https://operator.dezky.eu + # Cluster-internal address of platform-api (operator server routes + # forward the operator's access token to it). + - name: PLATFORM_API_INTERNAL_URL + value: http://platform-api.dezky-apps.svc.cluster.local:3001 + # OIDC client id/secret, NUXT_OIDC_REDIRECT_URI, NUXT_PUBLIC_AUTH_URL + # and the session secret come from the Secret. The OIDC client secret + # MUST equal authentik-secret.OPERATOR_OIDC_CLIENT_SECRET. + envFrom: + - secretRef: + name: operator-secrets + resources: + requests: + cpu: 100m + memory: 192Mi + limits: + memory: 512Mi + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 30 + periodSeconds: 30 +--- +apiVersion: v1 +kind: Service +metadata: + name: operator + namespace: dezky-apps + labels: + app.kubernetes.io/name: operator +spec: + selector: + app.kubernetes.io/name: operator + ports: + - name: http + port: 3000 + targetPort: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: operator + namespace: dezky-apps + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + # Serve on :80 too so the cert-manager ACME HTTP-01 solver can answer on + # port 80; the redirect-https middleware bounces all other traffic to HTTPS. + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: dezky-apps-redirect-https@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - operator.dezky.eu + secretName: operator-dezky-eu-tls + rules: + - host: operator.dezky.eu + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: operator + port: + number: 3000