From 3709a901248b4724574fd02368f8919e19749993 Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Thu, 15 Oct 2026 21:15:30 +0000 Subject: [PATCH] feat: deploy to Frostmoln production (#1019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the production deploy from AWS to the Frostmoln production cluster (namespace `schemas`). - `deploy-prod` (AWS) has failed since CI left AWS (no `k8s.unbound.se` context on the runners). It's replaced by `deploy-prod-frostmoln`, as in unbound-site/lingonpaj-site: `deploy prod-frostmoln -n schemas` with the org's `FROSTMOLN_PRODUCTION_DEPLOY_KEY`, `FROSTMOLN_PRODUCTION_CLUSTER_ID` and `BUILDTOOLS_FROSTMOLN_PRODUCTION`. - Restricted Pod Security: the image runs as `node` (uid 1000) with a matching securityContext. The service only writes under `/tmp` (cosmo composition). - `secrets.yaml` (AWS ClusterSecretStore, RDS + CloudAMQP) → `secrets-prod-frostmoln.yaml`: the namespace's `frostmoln` SecretStore and the Frostmoln Secret `schemas` (`POSTGRES_URL`, `AMQP_URL`, `API_KEY`), written by infra's `seed-schemas.sh`. The file had no target suffix, so it applied to every target. - `config-prod`/`pdb-prod` → `-prod-frostmoln`; the ALB ingress → a Traefik one for `/query` with a Let's Encrypt certificate. Checked locally (amd64): runs as uid 1000 with all capabilities dropped, `wgc` works, `/tmp` writable; it fails only on the (dummy) database, as expected. **Merge after** infra#1878 is applied and `seed-schemas.sh` + `migrate-schemas-db.sh` have run. `schemas.unbound.se` keeps pointing at AWS until the cut-over. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ma8eHhW59hLAJuLzBnLvUu Reviewed-on: https://git.unbound.se/unboundsoftware/schemas/pulls/1019 --- .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 a2c078d..e6a50cd 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