From d041432772aa6515667c400df387a1e0c2827bfb Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Wed, 14 Oct 2026 22:15:38 +0200 Subject: [PATCH] feat: deploy to Frostmoln production The AWS deploy-prod job has failed since CI left AWS (no k8s.unbound.se context on the runners). Deploys now go to the Frostmoln production cluster (namespace schemas), the way the sites do. Frostmoln enforces restricted Pod Security: the pod runs as the image's non-root user with a matching securityContext. The prod-only manifests move to the prod-frostmoln target; the ALB ingress becomes a Traefik one with a Let's Encrypt certificate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ma8eHhW59hLAJuLzBnLvUu --- .gitea/workflows/ci.yaml | 22 +++++++++++++---- Dockerfile | 4 ++++ ...g-prod.yaml => config-prod-frostmoln.yaml} | 0 k8s/deploy.yaml | 9 +++++++ k8s/ingress-prod-frostmoln.yaml | 20 ++++++++++++++++ k8s/ingress-prod.yaml | 24 ------------------- ...{pdb-prod.yaml => pdb-prod-frostmoln.yaml} | 0 k8s/secrets-prod-frostmoln.yaml | 17 +++++++++++++ k8s/secrets.yaml | 24 ------------------- 9 files changed, 68 insertions(+), 52 deletions(-) rename k8s/{config-prod.yaml => config-prod-frostmoln.yaml} (100%) create mode 100644 k8s/ingress-prod-frostmoln.yaml delete mode 100644 k8s/ingress-prod.yaml rename k8s/{pdb-prod.yaml => pdb-prod-frostmoln.yaml} (100%) create mode 100644 k8s/secrets-prod-frostmoln.yaml delete mode 100644 k8s/secrets.yaml diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c6fea6b..4480f37 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -83,16 +83,30 @@ jobs: - name: Build and push run: unset GITEA_TOKEN && build && push - deploy-prod: + deploy-prod-frostmoln: needs: build if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest env: - BUILDTOOLS_CONTENT: ${{ secrets.BUILDTOOLS_CONTENT }} + BUILDTOOLS_CONTENT: ${{ secrets.BUILDTOOLS_FROSTMOLN_PRODUCTION }} GITEA_REPOSITORY: ${{ gitea.repository }} + FM_TENANT: 9ae61342-0f76-476f-8176-e46f6b0b0c32 environment: prod steps: - uses: actions/checkout@v7 - uses: buildtool/setup-buildtools-action@v1 - - name: Deploy to production - run: deploy prod + - name: Install fm + run: | + sudo install -d -m 0755 /etc/apt/keyrings + sudo curl -fsSL -o /etc/apt/keyrings/frostmoln.gpg https://get.frostmoln.cloud/deb/frostmoln.gpg + sudo curl -fsSL -o /etc/apt/sources.list.d/frostmoln.sources https://get.frostmoln.cloud/deb/frostmoln.sources + sudo apt-get update && sudo apt-get install -y fm + - name: Deploy to Frostmoln production + env: + FROSTMOLN_API_KEY: ${{ secrets.FROSTMOLN_PRODUCTION_DEPLOY_KEY }} + # The cluster API is private: the kubeconfig's exec plugin opens an fm + # tunnel on demand. fm reads the key from its config file, not the env. + run: | + fm auth api-key set "$FROSTMOLN_API_KEY" > /dev/null + fm kubernetes cluster kubeconfig ${{ vars.FROSTMOLN_PRODUCTION_CLUSTER_ID }} --exec --file "$RUNNER_TEMP/kubeconfig" + KUBECONFIG_CONTENT="$(cat "$RUNNER_TEMP/kubeconfig")" deploy prod-frostmoln -n schemas diff --git a/Dockerfile b/Dockerfile index 46d4806..9609241 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,4 +42,8 @@ COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Copy the service binary COPY --from=build /release/service / +# The image's own non-root user: Frostmoln enforces restricted Pod Security. +# The service only writes under /tmp (cosmo composition). +USER node + CMD ["/service"] diff --git a/k8s/config-prod.yaml b/k8s/config-prod-frostmoln.yaml similarity index 100% rename from k8s/config-prod.yaml rename to k8s/config-prod-frostmoln.yaml diff --git a/k8s/deploy.yaml b/k8s/deploy.yaml index c29591f..26d8cc0 100644 --- a/k8s/deploy.yaml +++ b/k8s/deploy.yaml @@ -26,6 +26,11 @@ spec: labels: app.kubernetes.io/name: schemas spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -40,6 +45,10 @@ spec: topologyKey: kubernetes.io/hostname containers: - name: schemas + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] resources: requests: cpu: "100m" diff --git a/k8s/ingress-prod-frostmoln.yaml b/k8s/ingress-prod-frostmoln.yaml new file mode 100644 index 0000000..9fa19ba --- /dev/null +++ b/k8s/ingress-prod-frostmoln.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: schemas + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt +spec: + ingressClassName: traefik + rules: + - host: "schemas.unbound.se" + http: + paths: + - path: /query + pathType: Prefix + backend: + service: + name: schemas + port: + name: api diff --git a/k8s/ingress-prod.yaml b/k8s/ingress-prod.yaml deleted file mode 100644 index ca32008..0000000 --- a/k8s/ingress-prod.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: schemas-ingress - annotations: - alb.ingress.kubernetes.io/group.name: "default" - alb.ingress.kubernetes.io/scheme: internet-facing - alb.ingress.kubernetes.io/target-type: instance - alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80},{"HTTPS": 443}]' - alb.ingress.kubernetes.io/ssl-redirect: "443" - alb.ingress.kubernetes.io/healthcheck-path: '/health' -spec: - ingressClassName: "alb" - rules: - - host: "schemas.unbound.se" - http: - paths: - - path: /query - pathType: ImplementationSpecific - backend: - service: - name: schemas - port: - name: api diff --git a/k8s/pdb-prod.yaml b/k8s/pdb-prod-frostmoln.yaml similarity index 100% rename from k8s/pdb-prod.yaml rename to k8s/pdb-prod-frostmoln.yaml diff --git a/k8s/secrets-prod-frostmoln.yaml b/k8s/secrets-prod-frostmoln.yaml new file mode 100644 index 0000000..217dd0c --- /dev/null +++ b/k8s/secrets-prod-frostmoln.yaml @@ -0,0 +1,17 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: schemas +spec: + refreshInterval: 1h + # The namespace's store (infra frostmoln/production/schemas.tf); the + # Frostmoln Secret holds POSTGRES_URL, AMQP_URL and API_KEY + # (infra frostmoln/production/scripts/seed-schemas.sh). + secretStoreRef: + name: frostmoln + kind: SecretStore + target: + name: schemas + dataFrom: + - extract: + key: schemas diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml deleted file mode 100644 index 31bcb74..0000000 --- a/k8s/secrets.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: schemas -spec: - refreshInterval: 1h - secretStoreRef: - name: external-secrets - kind: ClusterSecretStore - target: - creationPolicy: Owner - template: - mergePolicy: Merge - engineVersion: 'v2' - data: - POSTGRES_URL: "postgres://{{ .DB_USERNAME }}:{{ .DB_PASSWORD }}@{{ .DB_HOST }}:{{ .DB_PORT }}/schemas?sslmode=disable" - API_KEY: "{{ .API_KEY }}" - dataFrom: - - extract: - key: services/schemas - - extract: - key: rds/postgres/prod-psql - - extract: - key: mq/rabbit/prod