From d68d20c6f4fb69f0069b5401f67cb8eb2fce68d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Sat, 29 Aug 2026 13:10:05 +0200 Subject: [PATCH] refactor: make this a reusable deployment, not ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every environment value is a ${VAR} for the caller to substitute: hostname, images, storage, ingress class, TLS issuer, Vault server and path, timezone, retention. Nothing in k8s/ names a host, a realm, an agent or an estate. Removed: the Flux plumbing under dev/, which described one deployment, and the ExternalSecret pulling a specific agent's Keycloak client secret. Both belong to whoever deploys this. The lab CA ConfigMap goes with them — the mount stays, parameterised and marked optional, because 'trust a private CA' is a property of an environment and not of n8n. The README is now the template's: how to consume it, what every variable means, and the three things it deliberately does not do. Co-Authored-By: Claude Opus 5 --- README.md | 126 +++++++++++++++----------- dev/bootstrap-kustomization.yaml | 30 ------ dev/flux-kustomization.yaml | 14 --- dev/gitrepository.yaml | 14 --- k8s/ca-configmap.yaml | 36 -------- k8s/ingress/certificate.yaml | 15 ++- k8s/ingress/ingress.yaml | 10 +- k8s/kustomization.yaml | 2 - k8s/n8n/configmap.yaml | 28 +++--- k8s/n8n/deployment.yaml | 34 ++++--- k8s/n8n/pvc.yaml | 4 +- k8s/n8n/service.yaml | 2 +- k8s/namespace.yaml | 2 +- k8s/postgres/service.yaml | 2 +- k8s/postgres/statefulset.yaml | 6 +- k8s/secrets/agent-externalsecret.yaml | 24 ----- k8s/secrets/clustersecretstore.yaml | 33 ++++--- k8s/secrets/externalsecret.yaml | 6 +- 18 files changed, 141 insertions(+), 247 deletions(-) delete mode 100644 dev/bootstrap-kustomization.yaml delete mode 100644 dev/flux-kustomization.yaml delete mode 100644 dev/gitrepository.yaml delete mode 100644 k8s/ca-configmap.yaml delete mode 100644 k8s/secrets/agent-externalsecret.yaml diff --git a/README.md b/README.md index 5c738f1..a1dedf3 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,95 @@ # infra-n8n -n8n on the lab cluster: the runtime for the estate's autonomous agents, -`Approvisionnement` and `Recouvrement`, whose workflows live in -`classic-models-agent-flows`. +A reusable n8n deployment: Postgres, the workload, TLS and an ingress, with +every environment value left as a `${VAR}` for the caller to substitute. -Nothing business-specific is here. This repo deploys the engine; the workflows -are imported into it. +**Nothing here is specific to a deployment.** No hostname, no issuer, no Vault +path, no image tag. A consumer supplies those through its own Flux +`Kustomization` and its `postBuild.substitute` block — the same shape used for +any templated estate. This repo is the template; it is not itself deployed. -## Layout +## Using it -| | | +Create, in the repo that owns the deployment: + +```yaml +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: { name: infra-n8n, namespace: } +spec: + interval: 5m + url: + ref: { branch: main } +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: { name: n8n, namespace: } +spec: + interval: 10m + sourceRef: { kind: GitRepository, name: infra-n8n } + path: ./k8s + prune: true + wait: true + postBuild: + substitute: + NAMESPACE: + # … every variable in the table below +``` + +The namespace must exist first, and so must the AppRole Secret the store reads. + +## Variables + +| Variable | What it is | |---|---| -| `k8s/postgres/` | the database, a StatefulSet with its own volume claim | -| `k8s/n8n/` | the workload, its config and its data volume | -| `k8s/ingress/` | a cert-manager `Certificate` and the nginx `Ingress` | -| `k8s/secrets/` | the `ClusterSecretStore` for this estate and the `ExternalSecret` | -| `dev/` | the Flux plumbing that applies `k8s/`, and manages itself | - -Reachable at **https://n8n.sttlab.local** once `192.168.139.101` (ingress-nginx) -resolves for that name. +| `NAMESPACE` | where everything lands | +| `N8N_HOSTNAME` | the public host. It goes into `WEBHOOK_URL` and the editor's links, so it must be what callers outside the cluster use — an in-cluster name here produces webhooks nothing can call | +| `N8N_IMAGE`, `POSTGRES_IMAGE` | pinned images, tag included | +| `N8N_STORAGE`, `POSTGRES_STORAGE` | claim sizes, e.g. `2Gi`, `5Gi` | +| `TIMEZONE` | e.g. `Europe/Paris`; drives cron triggers | +| `INGRESS_CLASS`, `INGRESS_BODY_SIZE` | e.g. `nginx`, `"50m"` | +| `TLS_ISSUER_NAME`, `TLS_ISSUER_KIND` | a cert-manager issuer, e.g. `ClusterIssuer` | +| `VAULT_SERVER`, `VAULT_MOUNT`, `VAULT_SECRET_PATH` | where the two secrets live | +| `VAULT_STORE_NAME`, `VAULT_APPROLE_SECRET` | the `ClusterSecretStore` this creates, and the AppRole Secret in `external-secrets` it authenticates with | +| `EXTRA_ENV_SECRET` | a Secret whose keys become environment variables for workflows. Optional in intent — supply an empty Secret if there is nothing | +| `EXTRA_CA_CONFIGMAP` | a ConfigMap with `ca.crt`, mounted and set as `NODE_EXTRA_CA_CERTS`, so workflows can call internal TLS endpoints without verification being disabled. Marked `optional`, so an absent one is not an error | +| `BLOCK_ENV_ACCESS` | `"true"` blocks `$env` in workflow expressions. n8n's default, and the right one wherever more than one team writes workflows: any workflow can otherwise read any variable in the pod. `"false"` is how a secret reaches a workflow without being committed in its JSON | +| `EXECUTIONS_MAX_AGE_HOURS` | how long executions are kept. n8n keeps them for ever by default, which turns the database into a log | ## Two secrets, both of which must outlive any pod -They live in Vault at `secrets/n8n/instance`, read through the `vault-n8n` -store — an AppRole whose policy can read `n8n/*` and nothing else, so a -compromise here is not a way into the keycloak estate's secrets. +Read from `VAULT_SECRET_PATH` as a whole, so the entry must hold exactly: - `DB_PASSWORD` - `N8N_ENCRYPTION_KEY` — encrypts the credentials n8n stores in its database. **Never regenerate it.** A new key does not re-encrypt what is stored; it orphans it. The workflows survive, every credential in them does not. -## Bootstrap - -Three manual applies, once. The Vault side is set up first — the commands are in +The AppRole's policy should reach `VAULT_SECRET_PATH` and nothing else, so a +workload here cannot read another deployment's secrets. The commands are in `k8s/secrets/clustersecretstore.yaml`. -``` -kubectl apply -f k8s/namespace.yaml -kubectl apply -f dev/gitrepository.yaml -kubectl apply -f dev/bootstrap-kustomization.yaml -``` - -After that everything reconciles from git, `dev/` included. Never `kubectl -apply` the objects under `k8s/` afterwards — Flux reverts them within its -interval and the cluster silently stops matching what you edited. - ## Choices worth knowing -**One replica, and `strategy: Recreate`.** n8n splits editor from workers only -in queue mode, which needs Redis; without it a second replica is two schedulers -racing over the same cron triggers. And the data volume is ReadWriteOnce, so a -rolling update would deadlock — the new pod waiting for a volume the old one has -not released. +**One replica, and `strategy: Recreate`.** n8n separates editor from workers +only in queue mode, which needs Redis; without it a second replica is two +schedulers racing over the same cron triggers. And the data volume is +ReadWriteOnce, so a rolling update deadlocks — the new pod waiting for a volume +the old one has not released. -**The certificate is issued, not committed.** cert-manager writes `n8n-tls` from -the `sttlab-local-ca` ClusterIssuer. The private key stays in the cluster. +**Postgres is a StatefulSet with a `volumeClaimTemplate`.** The claim is not +deleted with the workload, so losing the database to a `kubectl delete` takes +more than one mistake. -**The CA certificate *is* committed**, in `k8s/ca-configmap.yaml`, and mounted -as `NODE_EXTRA_CA_CERTS`. It is a certificate, never a key, and checking it in -means a workflow can call `keycloak.sttlab.local` and the domain gateways -without anyone disabling verification to make it work. Regenerate on rotation — -it expires 2026-11-24. +**The certificate is issued, never committed.** The private key is generated in +the cluster and rotated by cert-manager. -**`N8N_HOST` and `WEBHOOK_URL` are the ingress host, not the Service.** They go -into the webhook URLs n8n hands out; advertised as the in-cluster name, they -would be URLs nothing outside can call. +## What this repo deliberately does not do -**Executions are pruned after two weeks.** The default keeps them for ever, -which turns the database into a log. +**Authentication.** n8n's own user management is left at its defaults. A fresh +instance is *unclaimed*: anyone who can reach it can create the owner account. +Claim it immediately, and put it behind an identity-aware proxy or n8n's SSO +before it holds anything that matters. -## What is deliberately not here - -Authentication. n8n's own user management is left at its defaults, and the -instance is reachable to anyone who can resolve the host on the lab network. -Before it holds anything that matters, it needs either n8n's SSO against the -`classic-models` realm or an `openid-connect` filter at the ingress — the same -Keycloak the rest of the estate already uses. +**Backups.** The Postgres claim survives a delete of the workload; it does not +survive the cluster. diff --git a/dev/bootstrap-kustomization.yaml b/dev/bootstrap-kustomization.yaml deleted file mode 100644 index 0f2192b..0000000 --- a/dev/bootstrap-kustomization.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Watches ./dev, so the GitRepository and the Kustomization below it reconcile -# from git like any other manifest. It lives in ./dev itself, so it also manages -# itself — the arrangement `flux bootstrap` sets up, which flux-operator does not -# create, and the same one the rest of the estate uses. -# -# Three manual applies, once, to close the loop — nothing can watch ./dev until -# the namespace and the source that serves it exist: -# -# kubectl apply -f k8s/namespace.yaml -# kubectl apply -f dev/gitrepository.yaml -# kubectl apply -f dev/bootstrap-kustomization.yaml -# -# After that every file in this repo, this one included, reconciles from git. -# -# Sharp edge: with prune enabled, deleting flux-kustomization.yaml from git -# deletes the Kustomization it defines, and that Kustomization's own prune then -# removes the deployment — including, on the PVCs, the workflows. The -# volumeClaimTemplate on Postgres survives that; n8n-data does not. -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: infra-n8n-bootstrap - namespace: n8n -spec: - interval: 10m - sourceRef: - kind: GitRepository - name: infra-n8n - path: ./dev - prune: true diff --git a/dev/flux-kustomization.yaml b/dev/flux-kustomization.yaml deleted file mode 100644 index 32bce61..0000000 --- a/dev/flux-kustomization.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: kustomize.toolkit.fluxcd.io/v1 -kind: Kustomization -metadata: - name: infra-n8n - namespace: n8n -spec: - interval: 10m - sourceRef: - kind: GitRepository - name: infra-n8n - path: ./k8s - prune: true - wait: true - timeout: 5m diff --git a/dev/gitrepository.yaml b/dev/gitrepository.yaml deleted file mode 100644 index b150558..0000000 --- a/dev/gitrepository.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# HTTPS with no credentials: the repo is anonymously readable over HTTPS on -# gitea, so a deploy key would guard nothing while adding a Vault entry and a -# gitea key to create by hand before anything deploys. TLS still gives integrity -# and authenticity of the source. -apiVersion: source.toolkit.fluxcd.io/v1 -kind: GitRepository -metadata: - name: infra-n8n - namespace: n8n -spec: - interval: 5m - url: https://gitea.sttlab.eu/stt/infra-n8n.git - ref: - branch: main diff --git a/k8s/ca-configmap.yaml b/k8s/ca-configmap.yaml deleted file mode 100644 index 74857d2..0000000 --- a/k8s/ca-configmap.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# The lab CA, so n8n can verify keycloak.sttlab.local and the domain gateways -# rather than disabling verification. A CA certificate is public by -# construction — this is the certificate, never the key, and it is checked in so -# the deployment has no hidden prerequisite. -# -# Issued by the sttlab-local-ca ClusterIssuer, valid until 2026-11-24. -# Regenerate on rotation: -# kubectl -n cert-manager get secret sttlab-local-ca \ -# -o jsonpath='{.data.tls\.crt}' | base64 -d -apiVersion: v1 -kind: ConfigMap -metadata: - name: sttlab-ca - namespace: n8n -data: - ca.crt: | - -----BEGIN CERTIFICATE----- - MIIDPDCCAiSgAwIBAgIUd/utDf+f0yQR3v3qTjZqx+wvTZMwDQYJKoZIhvcNAQEL - BQAwGTEXMBUGA1UEAwwOKi5zdHRsYWIubG9jYWwwHhcNMjUxMTI0MTUzODQ1WhcN - MjYxMTI0MTUzODQ1WjAZMRcwFQYDVQQDDA4qLnN0dGxhYi5sb2NhbDCCASIwDQYJ - KoZIhvcNAQEBBQADggEPADCCAQoCggEBALj0sryq8qXtkShT/B+/EjVJNN/TLHIn - QJYc6xuUH/f7q3dYdLm4iH0XnFgrSQLUo4iqd/Mczef5iWCYwT5FFa7P17/TcLVf - gso0j41qNLBjGxDQGQ6Z1VCxi6oan8ow98rumjVj0E0Qgs5CX/rN+gcEmZ/TZFWU - qudVoztpeiVqJ0dxCIq+KMutzd1BU7BXx4VRO9Y+t1idUc7oY7BPmS2qPAN7TtXP - j1KhH2BgLsmF5GRhYHNS578eCltcmS2sbQ3c7GwOV96lpgF9TixEvFXeN4xk43E6 - 1COUD7CRiWAZ0XI8FMlpZGeNZeV4bbV/MqVig1ybWNOz9MDKYj3eoXUCAwEAAaN8 - MHowHQYDVR0OBBYEFDbzYWOPr4vxa+VONs7xHbcNx70YMB8GA1UdIwQYMBaAFDbz - YWOPr4vxa+VONs7xHbcNx70YMA8GA1UdEwEB/wQFMAMBAf8wJwYDVR0RBCAwHoIO - Ki5zdHRsYWIubG9jYWyCDHN0dGxhYi5sb2NhbDANBgkqhkiG9w0BAQsFAAOCAQEA - tU4p5/ONXm7x4YDzSeL8NdThVLguiFur0jcTxvvPUS4NFNsnQJk4OXxBgjy1YwpL - 3i56/4mCEd9OCQF/Hl0eFwGZBzNKlMoTA08AC/lBDvPrnVjuUu0qe/b/2KOkhESa - x8rQNp0o8pcj0f29nu0JfRqYGkzy0Lh2qgXhPu2iC+t2PfdleRAky1tt68wogdFL - TvHDsJbVo/ihsPpONlSyHfxG6bb/0Mkz64PH46TTt9GvJflEKMqDwivNFUA1P5S/ - U4Ic8Qapnxc7h2+WbRjmYrnuygC2wMBE6rL9OF3gwCr1fkgGGRr5lVRUq2X2HZV5 - 5jqViv6eurq8O61N3vWGCQ== - -----END CERTIFICATE----- diff --git a/k8s/ingress/certificate.yaml b/k8s/ingress/certificate.yaml index 691bca8..8bb7988 100644 --- a/k8s/ingress/certificate.yaml +++ b/k8s/ingress/certificate.yaml @@ -1,17 +1,16 @@ -# cert-manager issues this into the `n8n-tls` Secret, from the same -# ClusterIssuer as the rest of the estate. +# cert-manager issues this into the `n8n-tls` Secret. # -# Deliberately not a copy of a certificate committed to git: the private key -# stays in the cluster, is rotated by cert-manager, and never reaches a repo. +# Deliberately not a certificate copied into the repo: the private key is +# generated in the cluster, rotated by cert-manager, and never reaches git. apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: n8n-tls - namespace: n8n + namespace: ${NAMESPACE} spec: secretName: n8n-tls dnsNames: - - n8n.sttlab.local + - ${N8N_HOSTNAME} issuerRef: - name: sttlab-local-ca - kind: ClusterIssuer + name: ${TLS_ISSUER_NAME} + kind: ${TLS_ISSUER_KIND} diff --git a/k8s/ingress/ingress.yaml b/k8s/ingress/ingress.yaml index 990727a..d95b628 100644 --- a/k8s/ingress/ingress.yaml +++ b/k8s/ingress/ingress.yaml @@ -2,24 +2,24 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: n8n - namespace: n8n + namespace: ${NAMESPACE} annotations: # The editor pushes execution progress over a long-lived connection, and # imports can be large. Buffering it defeats the first; the default body # size defeats the second. - nginx.ingress.kubernetes.io/proxy-body-size: "50m" + nginx.ingress.kubernetes.io/proxy-body-size: ${INGRESS_BODY_SIZE} nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-buffering: "off" nginx.ingress.kubernetes.io/proxy-http-version: "1.1" spec: - ingressClassName: nginx + ingressClassName: ${INGRESS_CLASS} tls: - hosts: - - n8n.sttlab.local + - ${N8N_HOSTNAME} secretName: n8n-tls rules: - - host: n8n.sttlab.local + - host: ${N8N_HOSTNAME} http: paths: - path: / diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 8494cba..ac9fb54 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -2,10 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - namespace.yaml - - ca-configmap.yaml - secrets/clustersecretstore.yaml - secrets/externalsecret.yaml - - secrets/agent-externalsecret.yaml - postgres/statefulset.yaml - postgres/service.yaml - n8n/pvc.yaml diff --git a/k8s/n8n/configmap.yaml b/k8s/n8n/configmap.yaml index 5c94c5a..7327a4b 100644 --- a/k8s/n8n/configmap.yaml +++ b/k8s/n8n/configmap.yaml @@ -4,21 +4,21 @@ apiVersion: v1 kind: ConfigMap metadata: name: n8n-config - namespace: n8n + namespace: ${NAMESPACE} data: - GENERIC_TIMEZONE: Europe/Paris - TZ: Europe/Paris + GENERIC_TIMEZONE: ${TIMEZONE} + TZ: ${TIMEZONE} NODE_ENV: production # What n8n believes it is reachable at. It goes into the webhook URLs it # generates and into the links it puts in its own UI, so it must be the # ingress host and not the Service — a webhook advertised as `n8n` inside the # cluster is a webhook nothing outside can call. - N8N_HOST: n8n.sttlab.local + N8N_HOST: ${N8N_HOSTNAME} N8N_PORT: "5678" N8N_PROTOCOL: https - WEBHOOK_URL: https://n8n.sttlab.local/ - N8N_EDITOR_BASE_URL: https://n8n.sttlab.local/ + WEBHOOK_URL: https://${N8N_HOSTNAME}/ + N8N_EDITOR_BASE_URL: https://${N8N_HOSTNAME}/ # TLS terminates at the ingress, so n8n itself serves plain HTTP and must be # told it is behind a proxy — otherwise it reads the hop's address instead of @@ -28,18 +28,14 @@ data: # The default is to keep every execution for ever, which turns the database # into a log. Two weeks is long enough to debug a failed run. EXECUTIONS_DATA_PRUNE: "true" - EXECUTIONS_DATA_MAX_AGE: "336" + EXECUTIONS_DATA_MAX_AGE: ${EXECUTIONS_MAX_AGE_HOURS} N8N_DIAGNOSTICS_ENABLED: "false" N8N_VERSION_NOTIFICATIONS_ENABLED: "false" N8N_RUNNERS_ENABLED: "true" - # Lets a workflow read `$env.`. n8n blocks this by default, which is the - # right default for an instance where anyone can write a workflow — here it is - # how an agent's client secret reaches the workflow that acts as that agent, - # without the secret being stored in the workflow JSON and committed. - # - # It does mean any workflow can read any variable in the pod. That is - # acceptable while the instance runs this estate's own agents and nothing - # else; it stops being acceptable the moment it is shared. - N8N_BLOCK_ENV_ACCESS_IN_NODE: "false" + # "false" lets a workflow read `$env.`, which is how a secret reaches a + # workflow without being stored in the workflow JSON. n8n blocks it by + # default, and that default is right wherever more than one team can write a + # workflow: any workflow can then read any variable in the pod. + N8N_BLOCK_ENV_ACCESS_IN_NODE: ${BLOCK_ENV_ACCESS} diff --git a/k8s/n8n/deployment.yaml b/k8s/n8n/deployment.yaml index f9ae77e..f945c9d 100644 --- a/k8s/n8n/deployment.yaml +++ b/k8s/n8n/deployment.yaml @@ -2,7 +2,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: n8n - namespace: n8n + namespace: ${NAMESPACE} spec: # One replica. n8n's queue mode splits the editor from workers and needs Redis # to do it; without that, a second replica is two schedulers racing over the @@ -26,7 +26,7 @@ spec: fsGroup: 1000 initContainers: - name: wait-for-postgres - image: postgres:16.10-alpine + image: ${POSTGRES_IMAGE} command: - sh - -c @@ -36,7 +36,7 @@ spec: done containers: - name: n8n - image: docker.n8n.io/n8nio/n8n:2.37.4 + image: ${N8N_IMAGE} ports: - name: http containerPort: 5678 @@ -64,24 +64,27 @@ spec: secretKeyRef: name: n8n-secrets key: N8N_ENCRYPTION_KEY - # The lab CA, so a workflow can call keycloak.sttlab.local and the - # domain gateways without disabling verification. n8n is Node, hence - # this variable rather than a trust store. + # A private CA to trust, so a workflow can call an internal + # endpoint over TLS without anyone disabling verification to make it + # work. n8n is Node, hence this variable rather than a trust store. + # Provided by the deployment; an empty ConfigMap disables it in + # practice, since the file will not exist and Node falls back to its + # own bundle. - name: NODE_EXTRA_CA_CERTS - value: /etc/sttlab-ca/ca.crt + value: /etc/extra-ca/ca.crt envFrom: - configMapRef: name: n8n-config - # The agents' Keycloak client secrets, reached from a workflow as - # `$env.APPROVISIONNEMENT_CLIENT_SECRET`. A workflow acts as its - # agent, so it is the workflow that must be able to exchange tokens. + # Whatever else this deployment's workflows need in their + # environment. Provided by the deployment, not by this repo: an + # empty Secret of that name is a valid answer. - secretRef: - name: n8n-agent-secrets + name: ${EXTRA_ENV_SECRET} volumeMounts: - name: data mountPath: /home/node/.n8n - - name: sttlab-ca - mountPath: /etc/sttlab-ca + - name: extra-ca + mountPath: /etc/extra-ca readOnly: true readinessProbe: httpGet: @@ -110,6 +113,7 @@ spec: - name: data persistentVolumeClaim: claimName: n8n-data - - name: sttlab-ca + - name: extra-ca configMap: - name: sttlab-ca + name: ${EXTRA_CA_CONFIGMAP} + optional: true diff --git a/k8s/n8n/pvc.yaml b/k8s/n8n/pvc.yaml index b9c0ba8..29887a6 100644 --- a/k8s/n8n/pvc.yaml +++ b/k8s/n8n/pvc.yaml @@ -5,9 +5,9 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: name: n8n-data - namespace: n8n + namespace: ${NAMESPACE} spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 2Gi + storage: ${N8N_STORAGE} diff --git a/k8s/n8n/service.yaml b/k8s/n8n/service.yaml index 200d60b..53c76b1 100644 --- a/k8s/n8n/service.yaml +++ b/k8s/n8n/service.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Service metadata: name: n8n - namespace: n8n + namespace: ${NAMESPACE} spec: selector: app: n8n diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml index 8389695..b8e69c7 100644 --- a/k8s/namespace.yaml +++ b/k8s/namespace.yaml @@ -1,4 +1,4 @@ apiVersion: v1 kind: Namespace metadata: - name: n8n + name: ${NAMESPACE} diff --git a/k8s/postgres/service.yaml b/k8s/postgres/service.yaml index d48dcbd..90a8033 100644 --- a/k8s/postgres/service.yaml +++ b/k8s/postgres/service.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Service metadata: name: n8n-postgres - namespace: n8n + namespace: ${NAMESPACE} spec: selector: app: n8n-postgres diff --git a/k8s/postgres/statefulset.yaml b/k8s/postgres/statefulset.yaml index eac8b48..e0af53a 100644 --- a/k8s/postgres/statefulset.yaml +++ b/k8s/postgres/statefulset.yaml @@ -6,7 +6,7 @@ apiVersion: apps/v1 kind: StatefulSet metadata: name: n8n-postgres - namespace: n8n + namespace: ${NAMESPACE} spec: serviceName: n8n-postgres replicas: 1 @@ -22,7 +22,7 @@ spec: fsGroup: 999 containers: - name: postgres - image: postgres:16.10-alpine + image: ${POSTGRES_IMAGE} ports: - name: postgres containerPort: 5432 @@ -68,4 +68,4 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 5Gi + storage: ${POSTGRES_STORAGE} diff --git a/k8s/secrets/agent-externalsecret.yaml b/k8s/secrets/agent-externalsecret.yaml deleted file mode 100644 index 658b86f..0000000 --- a/k8s/secrets/agent-externalsecret.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# The agents' Keycloak client secrets, for the workflows that act as those -# agents. Read from the realm's own Vault entry through the keycloak store, not -# copied into `n8n/*`: there is one source for a client secret, and rotating it -# in Vault rotates it everywhere rather than leaving a stale twin behind. -# -# It is a narrow grant — this ExternalSecret names the properties it wants, so -# the n8n namespace receives those and nothing else of the realm. -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: n8n-agent-secrets - namespace: n8n -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: vault-keycloak - target: - name: n8n-agent-secrets - data: - - secretKey: APPROVISIONNEMENT_CLIENT_SECRET - remoteRef: - key: keycloak/classic-models-realm - property: KC_SECRET_CLASSIC_MODELS_AGENT_APPROVISIONNEMENT diff --git a/k8s/secrets/clustersecretstore.yaml b/k8s/secrets/clustersecretstore.yaml index ceede64..a71db34 100644 --- a/k8s/secrets/clustersecretstore.yaml +++ b/k8s/secrets/clustersecretstore.yaml @@ -1,37 +1,36 @@ -# One AppRole per estate, each with a policy that can read only its own path. -# The n8n store cannot read `keycloak/*` and the keycloak store cannot read -# `n8n/*` — so a compromised workload in one namespace does not become a way -# into the other's secrets. +# Reads this deployment's secrets from Vault. Scope the AppRole's policy to the +# path this store reads and nothing more, so a workload here cannot reach +# another estate's secrets. # -# Its Vault side is created by hand, once: +# The Vault side is created once, outside this repo: # -# vault policy write n8n-readonly - <<'HCL' -# path "secrets/data/n8n/*" { capabilities = ["read"] } -# path "secrets/metadata/n8n/*" { capabilities = ["list", "read"] } +# vault policy write - <<'HCL' +# path "/data//*" { capabilities = ["read"] } +# path "/metadata//*" { capabilities = ["list", "read"] } # HCL -# vault write auth/approle/role/n8n-eso token_policies=n8n-readonly \ +# vault write auth/approle/role/ token_policies= \ # token_ttl=1h token_max_ttl=4h -# kubectl -n external-secrets create secret generic vault-approle-n8n \ -# --from-literal=roleId=$(vault read -field=role_id auth/approle/role/n8n-eso/role-id) \ -# --from-literal=secretId=$(vault write -f -field=secret_id auth/approle/role/n8n-eso/secret-id) +# kubectl -n external-secrets create secret generic ${VAULT_APPROLE_SECRET} \ +# --from-literal=roleId=$(vault read -field=role_id auth/approle/role//role-id) \ +# --from-literal=secretId=$(vault write -f -field=secret_id auth/approle/role//secret-id) apiVersion: external-secrets.io/v1 kind: ClusterSecretStore metadata: - name: vault-n8n + name: ${VAULT_STORE_NAME} spec: provider: vault: - server: https://vault.sttlab.eu - path: secrets + server: ${VAULT_SERVER} + path: ${VAULT_MOUNT} version: v2 auth: appRole: path: approle roleRef: - name: vault-approle-n8n + name: ${VAULT_APPROLE_SECRET} namespace: external-secrets key: roleId secretRef: - name: vault-approle-n8n + name: ${VAULT_APPROLE_SECRET} namespace: external-secrets key: secretId diff --git a/k8s/secrets/externalsecret.yaml b/k8s/secrets/externalsecret.yaml index f3b0d30..98f077e 100644 --- a/k8s/secrets/externalsecret.yaml +++ b/k8s/secrets/externalsecret.yaml @@ -8,15 +8,15 @@ apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: name: n8n-secrets - namespace: n8n + namespace: ${NAMESPACE} spec: refreshInterval: 1h secretStoreRef: kind: ClusterSecretStore - name: vault-n8n + name: ${VAULT_STORE_NAME} target: name: n8n-secrets creationPolicy: Owner dataFrom: - extract: - key: n8n/instance + key: ${VAULT_SECRET_PATH}