From 39a0b665a313ac4b5b0a92e49b0e3969bc7787f9 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 13 Aug 2026 11:45:49 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(helm)=20deploy=20new=20infra=20using?= =?UTF-8?q?=20helm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new infra we have must be configured in the helm chart. This commit all the missing templates to deploy yhub, it also automate the creation of the private keys needed by all services. --- .dockerignore | 7 + .github/workflows/docker-hub.yml | 15 + .github/workflows/ghcr.yml | 47 ++ CHANGELOG.md | 42 +- bin/Tiltfile | 13 + compose.yml | 15 +- documentation/collaboration.md | 47 +- .../examples/helm/impress.values.yaml | 24 +- documentation/format_conversion.md | 28 +- documentation/installation/kubernetes.md | 1 + src/helm/env.d/dev/values.impress.yaml.gotmpl | 56 ++- .../env.d/feature/values.impress.yaml.gotmpl | 39 +- src/helm/impress/README.md | 113 ++++- src/helm/impress/templates/_helpers.tpl | 108 ++++- .../templates/backend_cronjob_list.yaml | 20 +- .../impress/templates/backend_deployment.yaml | 20 +- src/helm/impress/templates/backend_job.yml | 20 +- .../backend_job_createsuperuser.yaml | 20 +- .../templates/backend_job_migrate.yaml | 20 +- .../templates/celery_worker_deployment.yaml | 20 +- .../templates/ingress_collaboration_api.yaml | 16 +- .../templates/ingress_collaboration_ws.yaml | 8 +- src/helm/impress/templates/jwt_keys_job.yaml | 124 ++++++ src/helm/impress/templates/jwt_keys_pvc.yaml | 32 ++ .../impress/templates/yhub_deployment.yaml | 187 ++++++++ .../impress/templates/yhub_job_init_db.yaml | 124 ++++++ src/helm/impress/templates/yhub_svc.yaml | 22 + .../templates/yprovider_deployment.yaml | 2 +- .../yprovider_deployment_converter.yaml | 189 --------- .../templates/yprovider_svc_converter.yaml | 25 -- src/helm/impress/values.yaml | 401 ++++++++++++++---- src/yhub-server/Dockerfile | 24 +- src/yhub-server/README.md | 10 +- 33 files changed, 1465 insertions(+), 374 deletions(-) create mode 100644 src/helm/impress/templates/jwt_keys_job.yaml create mode 100644 src/helm/impress/templates/jwt_keys_pvc.yaml create mode 100644 src/helm/impress/templates/yhub_deployment.yaml create mode 100644 src/helm/impress/templates/yhub_job_init_db.yaml create mode 100644 src/helm/impress/templates/yhub_svc.yaml delete mode 100644 src/helm/impress/templates/yprovider_deployment_converter.yaml delete mode 100644 src/helm/impress/templates/yprovider_svc_converter.yaml diff --git a/.dockerignore b/.dockerignore index dc3ed9896..e9235269a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,10 @@ __pycache__ **/*.pyc venv .venv +# the pattern above only matches at the root, and every image is built from +# there: without this one, the backend virtualenv travels to the daemon on +# every build +**/.venv # System-specific files .DS_Store @@ -34,4 +38,7 @@ db.sqlite3 # Frontend node_modules +# same as .venv above: nested ones are not matched by the pattern above, and no +# image copies them — every one of them runs its own install +**/node_modules **/.next diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index b0a5aed25..984b738a1 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -60,11 +60,26 @@ jobs: should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }} docker_user: 1001:127 + build-and-push-yhub: + uses: ./.github/workflows/docker-publish.yml + permissions: + contents: read + secrets: inherit + with: + image_name: lasuite/impress-yhub + context: . + file: src/yhub-server/Dockerfile + target: yhub + should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }} + # no docker_user: the image defaults to uid 1000, the `node` user the + # base image already declares in /etc/passwd + notify-argocd: needs: - build-and-push-backend - build-and-push-frontend - build-and-push-y-provider + - build-and-push-yhub runs-on: ubuntu-latest if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') steps: diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml index ac2fb71d0..00040dd63 100644 --- a/.github/workflows/ghcr.yml +++ b/.github/workflows/ghcr.yml @@ -158,3 +158,50 @@ jobs: run: | docker system prune -af docker volume prune -f + + build-and-push-yhub: + runs-on: ubuntu-latest + if: github.event.repository.fork == true + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }}/yhub + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + - name: Login to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 + with: + context: . + file: ./src/yhub-server/Dockerfile + target: yhub + platforms: linux/amd64,linux/arm64 + build-args: DOCKER_USER=${{ env.DOCKER_USER }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: Cleanup Docker after build + if: always() + run: | + docker system prune -af + docker volume prune -f diff --git a/CHANGELOG.md b/CHANGELOG.md index fce134502..4ffddf15e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,22 @@ and this project adheres to on its own - 🔧(dev) generate the JWT signing key of the collaboration server when bootstrapping the dev stack, alongside the backend one +- ✨(helm) generate the JWT signing keys of the services on the cluster + (`jwtKeys.enabled`, off by default): a job writes the backend and the + collaboration server keys, once, into a volume both mount read-only, and sets + the `*_FILE` variables pointing at them. No key is templated into a manifest, + kept in a values file or read back from the kubernetes API, and nothing but + that job is granted a write to the volume. Idempotent — an existing key is + kept, so it re-runs on every sync, and rolling a key is deleting it and + letting the next run write a new one. The volume is mounted by every backend + pod and by the collaboration server, so its storage class must support + `ReadWriteMany` unless the cluster has a single node +- ✨(helm) deploy the collaboration server: the chart gains a `yhub` deployment, + its service, and the job running the `init-db` script that creates and + upgrades its schema (an ArgoCD sync wave before the deployment, since a + document read fails until it has run). Configured under the `yhub` values + key, where `REDIS` and `POSTGRES` are required and have no default; the image + is published as `lasuite/impress-yhub` - ✨(backend) serve `documents/{id}/formatted-content/` from yhub - ✨(backend) duplicate a document through the collaboration server - ✨(backend) call YHubService to seed initial document content @@ -197,13 +213,31 @@ and this project adheres to - 💥(backend) remove the `documents/{id}/content/` endpoint - 💥(backend) remove the `documents/{id}/can-edit/` endpoint - 💥(y-provider) the published `lasuite/impress-y-provider` image becomes - converter-only and no longer serves `/collaboration/ws/`; deployments using - the existing helm values lose collaboration until the helm chart routes - collaboration to yhub (follow-up) + converter-only and no longer serves `/collaboration/ws/` +- 💥(helm) route `/collaboration/` to yhub instead of the y-provider: both + collaboration ingresses now point at the yhub service, and + `ingressCollaborationApi` serves the routes yhub exposes to browsers + (`ingressCollaborationApi.paths`, one ingress rule each) instead of the + single `/collaboration/api/` path — what is not listed stays in-cluster, so + `create-ydoc`, `reset-connections`, `migrate`, `restore-ydoc` and + `reset-ydoc` are not published. The `upstream-hash-by: $arg_room` annotation + is dropped: yhub replicas exchange updates through redis, so a room needs no + sticky upstream — and hashing on a query argument its urls do not carry would + pin every connection to a single pod +- 💥(helm) drop the `yProvider.converter` values, its deployment and its + service: the y-provider serves nothing but the conversion API since the + collaboration moved to yhub, so the `yProvider` release *is* the converter + and there is no second one to enable. Deployments that had it on lose the + `-converter` suffix on the url the backend calls — + `Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/` — and + `yProvider.converter.*` values are now ignored, their `yProvider.*` + counterparts taking over - 🔧(collaboration) split the yhub image into a development and a production stage, like the other services: the dev stack now bind-mounts `src/yhub-server` and runs the server through nodemon, so editing a source - file restarts it instead of needing `make build-yhub` + file restarts it instead of needing `make build-yhub`. The production stage + gains the un-privileged user and the entrypoint the other images have, and + both are now built from the repository root like the rest of them ## [v5.4.1] - 2026-07-09 diff --git a/bin/Tiltfile b/bin/Tiltfile index edcea0a7a..032e23428 100644 --- a/bin/Tiltfile +++ b/bin/Tiltfile @@ -42,8 +42,21 @@ docker_build( ] ) +docker_build( + 'localhost:5001/impress-yhub:latest', + context='..', + dockerfile='../src/yhub-server/Dockerfile', + only=['./src/yhub-server', './docker', './.dockerignore'], + target = 'yhub', + build_args={'DOCKER_USER': '1000:1000'}, + live_update=[ + sync('../src/yhub-server', '/app'), + ] +) + k8s_resource('impress-docs-backend-migrate', resource_deps=['dev-backend-postgres']) k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate']) +k8s_resource('impress-docs-yhub-init-db', resource_deps=['dev-backend-postgres']) k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg']) k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate', 'dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'dev-backend-minio:statefulset']) k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .')) diff --git a/compose.yml b/compose.yml index 3c4b63239..a53036eb7 100644 --- a/compose.yml +++ b/compose.yml @@ -235,8 +235,8 @@ services: yhub: user: ${DOCKER_USER:-1000} build: - context: ./src/yhub-server - dockerfile: Dockerfile + context: . + dockerfile: ./src/yhub-server/Dockerfile target: yhub-development image: impress:yhub-development environment: @@ -255,6 +255,11 @@ services: - env.d/development/common.local volumes: - ./data/jwt:/data/jwt:ro + # editing a source file restarts the server (nodemon), no rebuild + - ./src/yhub-server:/app + # node_modules is installed in the image, not in the source tree: keep + # the bind mount above from hiding it + - /app/node_modules restart: unless-stopped ports: - "3002:3002" @@ -267,12 +272,6 @@ services: # starting before minio would cache 401s for the first accessed docs minio: condition: service_healthy - volumes: - # editing a source file restarts the server (nodemon), no rebuild - - ./src/yhub-server:/app - # node_modules is installed in the image, not in the source tree: keep - # the bind mount above from hiding it - - /app/node_modules kc_postgresql: image: postgres:14.3 diff --git a/documentation/collaboration.md b/documentation/collaboration.md index 873a87a80..c56669161 100644 --- a/documentation/collaboration.md +++ b/documentation/collaboration.md @@ -1,12 +1,57 @@ # Collaboration -By default with Docs, collaboration is enabled. To allow the collaboration between users, a connection to a websocket server is made (the y-provider service), you only have to configure the Django backend URL and the allowed origin in your y-provider service: +By default with Docs, collaboration is enabled. To allow the collaboration between users, a connection to a websocket server is made (the yhub service), you only have to configure the Django backend URL and the allowed origin in your yhub service: ```yaml COLLABORATION_BACKEND_BASE_URL: https://{yourdocsdomain.tld} COLLABORATION_SERVER_ORIGIN: https://{yourdocsdomain.tld} ``` +The collaboration server keeps the live state of a document in Redis and persists it to a PostgreSQL database of its own, so it needs both: + +```yaml +REDIS: redis://{redis-host}:6379/0 +POSTGRES: postgres://{user}:{password}@{postgres-host}:5432/yhub +``` + +Nothing creates that schema at startup: the server never runs DDL. Run the script yhub ships (`npm run init-db`, which the helm chart runs as a job) once before starting it, and again after every upgrade that adds a table. It creates the database when it is missing, it is idempotent, and until it has run every document read fails with `relation "..." does not exist`. + +The Django backend reads and writes document content there too, so point it at the service: + +```yaml +YHUB_API_BASE_URL: http://{yhub-service}:443 +``` + +Prefer the internal service url: the routes the backend calls are not meant to be reachable from the outside. Route `/collaboration/ws/` to the service publicly — that is the one the browsers open — plus the document routes (`/collaboration/ydoc/`, `rollback`, `prune`, `changeset`, `activity`) and `/collaboration/jwks/`, which carries public keys and nothing else. Keep `reset-connections`, `migrate`, `restore-ydoc`, `reset-ydoc` and `create-ydoc` in-cluster. + +Both directions are authenticated with short-lived RS256 JWTs rather than a shared secret, and each side verifies the other against the JWKS it publishes — so both need a signing key of their own, and neither needs a copy of the other's: + +```yaml +# Django +JWT_PRIVATE_KEY_FILE: /path/to/backend-private.pem +# yhub +YHUB_JWT_PRIVATE_KEY_FILE: /path/to/yhub-private.pem +``` + +They are ordinary PKCS#8 RSA keys (`openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`), and rolling one needs no change on the other side. Without them the documents still open and edit, but the backend cannot create, delete or restore a document's content, and yhub cannot tell it that a document changed — its `updated_at` stops following the edits. + +### Generating them on the cluster + +The helm chart generates both for you, so that no key has to be created by hand, put in a values file or in a secret: + +```yaml +jwtKeys: + enabled: true +``` + +A job then writes the two keys, once, into a volume every service mounts read-only, and points the backend and yhub at them. It keeps the keys it finds, so it is safe to re-run — it runs on every sync — and rolling a key is deleting it from the volume and letting the next run write a new one. Both sides follow: they pick the verification key by its `kid` and fetch the set again when they meet one they do not know. + +Every backend pod and the collaboration server mount that volume, which puts them on several nodes as soon as they have replicas, so the storage class behind it has to support `ReadWriteMany`. On a single node cluster, `jwtKeys.persistence.accessModes: [ReadWriteOnce]` does just as well. The volume outlives the release (`helm.sh/resource-policy: keep`), so uninstalling and installing again keeps the same identities; delete the claim to start over. + +Setting `JWT_PRIVATE_KEY_FILE` or `YHUB_JWT_PRIVATE_KEY_FILE` yourself keeps priority over what the job provides, so a deployment holding its keys in a secret of its own can leave `jwtKeys` disabled and mount them where it wants. + +Several replicas can serve the same document: they exchange updates through Redis, so no sticky routing is needed on the websocket ingress. + ## What happens when connection to the websocket is not allowed? When multiple users access a Docs and the connection to the websocket is not allowed, then they will be in a situation where they can lose data. diff --git a/documentation/examples/helm/impress.values.yaml b/documentation/examples/helm/impress.values.yaml index a6d26dab5..6eb1b2416 100644 --- a/documentation/examples/helm/impress.values.yaml +++ b/documentation/examples/helm/impress.values.yaml @@ -67,7 +67,9 @@ backend: AWS_STORAGE_BUCKET_NAME: docs-media-storage STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io - Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/ + # the collaboration server, reached in-cluster + YHUB_API_BASE_URL: http://impress-docs-yhub:443 + Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/ Y_PROVIDER_API_KEY: my-secret CACHES_KEY_PREFIX: "{{ now | unixEpoch }}" migrate: @@ -135,6 +137,26 @@ yProvider: COLLABORATION_LOGGING: true COLLABORATION_SERVER_ORIGIN: https://docs.127.0.0.1.nip.io +# The collaboration server: it serves everything under /collaboration/, the +# websocket included. It keeps the live state of a document in redis and +# persists it to a PostgreSQL database of its own, created by the init-db job +# the chart ships — give the user in POSTGRES the right to create it, or create +# the database yourself beforehand. +yhub: + replicas: 1 + + image: + repository: lasuite/impress-yhub + pullPolicy: Always + tag: "latest" + + envVars: + POSTGRES: postgres://dinum:pass@postgresql-dev-backend-postgres:5432/yhub + REDIS: redis://user:pass@redis-dev-backend-redis:6379/2 + REDIS_PREFIX: yhub + COLLABORATION_BACKEND_BASE_URL: https://docs.127.0.0.1.nip.io + COLLABORATION_SERVER_ORIGIN: https://docs.127.0.0.1.nip.io + ingress: enabled: true host: docs.127.0.0.1.nip.io diff --git a/documentation/format_conversion.md b/documentation/format_conversion.md index 828b82b1e..2ea13be7c 100644 --- a/documentation/format_conversion.md +++ b/documentation/format_conversion.md @@ -26,33 +26,17 @@ COLLABORATION_BACKEND_BASE_URL: http://{django-service}:8000 The JWKS url defaults to `{COLLABORATION_BACKEND_BASE_URL}/api/v1.0/jwks`; override it with `JWKS_URL` if Django is not reachable at that base url from the y-provider service. -### Splitting conversion service +### One service, not two anymore -The conversion service is present in the `y-provider` server. The same server used to manage websockets. You can split in one side the websocket server and in an other side the converter service. -This feature is only available in our helm chart, if you are deploying an other way you can take example of what is made to implement it. -The idea is to deploy twice the `y-provider` server, one dedicated for websockets and one dedicated to the conversion. +The `y-provider` server used to serve the websockets as well, which is why it could be deployed twice — one release for the collaboration, one for the conversion (`yProvider.converter`). The collaboration is served by [yhub](collaboration.md) now, so the conversion is all that is left: the `y-provider` service **is** the converter, and the `yProvider.converter` values are gone. -In the helm chart, you can use this value that will do the job for you: - -```yaml -yProvider: - converter: - enabled: true -``` - -Every parameter in the `yProvider` key can be overridden in the `yProvider.converter` key. - -Once enabled, you have to enable the `Y_PROVIDER_API_BASE_URL` with the url of the newly created service, it is the same as before with `-converter` at the end. -If before it was - -```yaml -Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/ -``` - -now it is +A deployment coming from a chart older than this one has one thing to change, the url the backend calls, which loses its suffix: ```yaml +# before Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider-converter:443/api/ +# now +Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/ ``` ## Docspec configuration diff --git a/documentation/installation/kubernetes.md b/documentation/installation/kubernetes.md index 41410e76b..e87eb7199 100644 --- a/documentation/installation/kubernetes.md +++ b/documentation/installation/kubernetes.md @@ -226,6 +226,7 @@ impress-docs-backend-8494fb797d-8k8wt 1/1 Running 0 6m45s impress-docs-celery-worker-764b5dd98f-9qd6v 1/1 Running 0 6m45s impress-docs-frontend-5b69b65cc4-s8pps 1/1 Running 0 6m45s impress-docs-y-provider-5fc7ccd8cc-6ttrf 1/1 Running 0 6m45s +impress-docs-yhub-6d84f9b7c5-2xqzp 1/1 Running 0 6m45s keycloak-dev-backend-keycloak-0 1/1 Running 0 24m keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 24m minio-dev-backend-minio-0 1/1 Running 0 8m24s diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 35130a086..bcf7c81ef 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -71,7 +71,10 @@ backend: STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io - Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider-converter:443/api/ + # the collaboration server, reached in-cluster: the backend reads and + # writes document content there, and fetches its JWKS from the same host + YHUB_API_BASE_URL: http://impress-docs-yhub:443 + Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/ Y_PROVIDER_API_KEY: my-secret CACHES_KEY_PREFIX: "{{ now | unixEpoch }}" django: @@ -153,11 +156,6 @@ frontend: runAsNonRoot: false yProvider: - - converter: - enabled: true - replicas: 2 - replicas: 1 image: @@ -186,6 +184,50 @@ yProvider: - key: cacert.pem path: cacert.pem +# The keys the backend and the collaboration server sign the calls they make to +# each other with, generated on the cluster by a job and mounted read-only in +# both. ReadWriteOnce because the local cluster has a single node. +jwtKeys: + enabled: true + persistence: + accessModes: + - ReadWriteOnce + +yhub: + replicas: 1 + + image: + repository: localhost:5001/impress-yhub + pullPolicy: Always + tag: "latest" + + envVars: + # its own logical database on the dev-backend postgres: the init-db job + # creates it, the backend never touches it + POSTGRES: postgres://dinum:pass@dev-backend-postgres:5432/yhub + # a redis database of its own too — the backend cache and celery live in /1 + REDIS: redis://user:pass@dev-backend-redis:6379/2 + REDIS_PREFIX: yhub + COLLABORATION_BACKEND_BASE_URL: https://docs.127.0.0.1.nip.io + COLLABORATION_SERVER_ORIGIN: https://docs.127.0.0.1.nip.io + NODE_EXTRA_CA_CERTS: /cert/cacert.pem + # YHUB_JWT_PRIVATE_KEY_FILE comes from the jwtKeys job below + + # Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false + extraVolumeMounts: + - name: certs + mountPath: /cert/cacert.pem + subPath: cacert.pem + + # Extra volumes to manage our local custom CA and avoid to set ssl_verify: false + extraVolumes: + - name: certs + configMap: + name: certifi + items: + - key: cacert.pem + path: cacert.pem + docSpec: enabled: true replicas: 1 @@ -212,7 +254,7 @@ ingressCollaborationWS: host: docs.127.0.0.1.nip.io ingressCollaborationApi: - enabled: true + enabled: false host: docs.127.0.0.1.nip.io ingressAdmin: diff --git a/src/helm/env.d/feature/values.impress.yaml.gotmpl b/src/helm/env.d/feature/values.impress.yaml.gotmpl index 4ea4e8e6c..efbc686ef 100644 --- a/src/helm/env.d/feature/values.impress.yaml.gotmpl +++ b/src/helm/env.d/feature/values.impress.yaml.gotmpl @@ -71,7 +71,10 @@ backend: STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion USER_RECONCILIATION_FORM_URL: https://{{ .Values.feature }}-docs.{{ .Values.domain }} - Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider-converter:443/api/ + # the collaboration server, reached in-cluster: the backend reads and + # writes document content there, and fetches its JWKS from the same host + YHUB_API_BASE_URL: http://impress-docs-yhub:443 + Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/ Y_PROVIDER_API_KEY: my-secret CACHES_KEY_PREFIX: "{{ now | unixEpoch }}" migrate: @@ -131,11 +134,6 @@ frontend: tag: *tag yProvider: - - converter: - enabled: true - replicas: 1 - replicas: 1 image: @@ -149,6 +147,33 @@ yProvider: COLLABORATION_SERVER_ORIGIN: https://{{ .Values.feature }}-docs.{{ .Values.domain }} NODE_OPTIONS: "--max-old-space-size=1024" +# The keys the backend and the collaboration server sign the calls they make to +# each other with, generated on the cluster by a job and mounted read-only in +# both. ReadWriteOnce because the preview cluster has a single node. +jwtKeys: + enabled: true + persistence: + accessModes: + - ReadWriteOnce + +yhub: + replicas: 1 + + image: + repository: lasuite/impress-yhub + pullPolicy: Always + tag: *tag + + envVars: + # its own logical database on the dev-backend postgres, created by the + # init-db job; redis /2, the backend cache and celery live in /1 + POSTGRES: postgres://dinum:pass@dev-backend-postgres:5432/yhub + REDIS: redis://user:pass@dev-backend-redis:6379/2 + REDIS_PREFIX: yhub + COLLABORATION_BACKEND_BASE_URL: https://{{ .Values.feature }}-docs.{{ .Values.domain }} + COLLABORATION_SERVER_ORIGIN: https://{{ .Values.feature }}-docs.{{ .Values.domain }} + NODE_OPTIONS: "--max-old-space-size=1024" + docSpec: enabled: true replicas: 1 @@ -176,7 +201,7 @@ ingressCollaborationWS: host: {{ .Values.feature }}-docs.{{ .Values.domain }} ingressCollaborationApi: - enabled: true + enabled: false host: {{ .Values.feature }}-docs.{{ .Values.domain }} ingressAdmin: diff --git a/src/helm/impress/README.md b/src/helm/impress/README.md index c8dcaf434..88fd24f8d 100644 --- a/src/helm/impress/README.md +++ b/src/helm/impress/README.md @@ -38,7 +38,6 @@ | `ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/enable-websocket` | | `true` | | `ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/proxy-read-timeout` | | `86400` | | `ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/proxy-send-timeout` | | `86400` | -| `ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/upstream-hash-by` | | `$arg_room` | | `ingressRedirects.enabled` | whether to enable the Ingress Redirects or not | `false` | | `ingressRedirects.className` | IngressClass to use for the Ingress Redirects | `nil` | | `ingressRedirects.host` | Host for the Ingress Redirects | `impress.example.com` | @@ -51,13 +50,13 @@ | `ingressCollaborationApi.className` | IngressClass to use for the Ingress | `nil` | | `ingressCollaborationApi.host` | Host for the Ingress | `impress.example.com` | | `ingressCollaborationApi.path` | Path to use for the Ingress | `/collaboration/api/` | +| `ingressCollaborationApi.paths` | Paths to route to the collaboration server, one rule each | `["/collaboration/ydoc/","/collaboration/jwks/"]` | | `ingressCollaborationApi.hosts` | Additional host to configure for the Ingress | `[]` | | `ingressCollaborationApi.tls.enabled` | Whether to enable TLS for the Ingress | `true` | | `ingressCollaborationApi.tls.secretName` | Secret name for TLS config | `nil` | | `ingressCollaborationApi.tls.additional[].secretName` | Secret name for additional TLS config | | | `ingressCollaborationApi.tls.additional[].hosts[]` | Hosts for additional TLS config | | | `ingressCollaborationApi.customBackends` | Add custom backends to ingress | `[]` | -| `ingressCollaborationApi.annotations.nginx.ingress.kubernetes.io/upstream-hash-by` | | `$arg_room` | | `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` | | `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` | | `ingressAdmin.host` | Host for the Ingress | `impress.example.com` | @@ -301,13 +300,121 @@ | `yProvider.pdb.enabled` | Enable pdb on yProvider | `true` | | `yProvider.serviceAccountName` | Optional service account name to use for yProvider pods | `nil` | +### JWT signing keys + +| Name | Description | Value | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------- | +| `jwtKeys.enabled` | Generate the JWT signing keys of the services on the cluster | `false` | +| `jwtKeys.mountPath` | Path the keys are mounted at, in every service reading them | `/data/jwt` | +| `jwtKeys.backendKeyFilename` | Name of the key signing the tokens the backend issues | `private.pem` | +| `jwtKeys.yhubKeyFilename` | Name of the key signing the calls the collaboration server makes to the backend | `yhub-private.pem` | +| `jwtKeys.keySize` | Size, in bits, of the generated RSA keys | `2048` | +| `jwtKeys.image.repository` | Repository to use to pull the image generating the keys | `alpine/openssl` | +| `jwtKeys.image.tag` | Tag of the image generating the keys | `3.5.7` | +| `jwtKeys.image.pullPolicy` | Pull policy of the image generating the keys | `IfNotPresent` | +| `jwtKeys.securityContext.allowPrivilegeEscalation` | Whether to allow privilege escalation for the containers waiting for the keys | `false` | +| `jwtKeys.securityContext.capabilities.drop` | List of capabilities to drop for the containers waiting for the keys | `["ALL"]` | +| `jwtKeys.securityContext.runAsNonRoot` | Whether to run the containers waiting for the keys as a non-root user | `true` | +| `jwtKeys.securityContext.seccompProfile.type` | Seccomp profile type for the containers waiting for the keys | `RuntimeDefault` | +| `jwtKeys.persistence.existingClaim` | Claim holding the keys, generated in a claim of the chart's own when empty | `nil` | +| `jwtKeys.persistence.size` | Size of the claim holding the keys, two RSA keys and nothing else | `8Mi` | +| `jwtKeys.persistence.storageClass` | Storage class of the claim holding the keys, the cluster default when empty | `nil` | +| `jwtKeys.persistence.accessModes` | Access modes of the claim holding the keys | `["ReadWriteMany"]` | +| `jwtKeys.persistence.annotations` | Annotations to add to the claim holding the keys | `{}` | +| `jwtKeys.job.podSecurityContext.fsGroup` | Group given ownership of the volume, so the generating container can write the keys in it | `1000` | +| `jwtKeys.job.securityContext.allowPrivilegeEscalation` | Whether to allow privilege escalation for the generating container | `false` | +| `jwtKeys.job.securityContext.capabilities.drop` | List of capabilities to drop for the generating container | `["ALL"]` | +| `jwtKeys.job.securityContext.runAsNonRoot` | Whether to run the generating container as a non-root user | `true` | +| `jwtKeys.job.securityContext.runAsUser` | User the generating container runs as, the openssl image declaring none | `1000` | +| `jwtKeys.job.securityContext.runAsGroup` | Group the generating container runs as | `1000` | +| `jwtKeys.job.securityContext.seccompProfile.type` | Seccomp profile type for the generating container | `RuntimeDefault` | +| `jwtKeys.job.restartPolicy` | Restart policy of the generating job | `Never` | +| `jwtKeys.job.backoffLimit` | Numbers of generating job retries | `2` | +| `jwtKeys.job.ttlSecondsAfterFinished` | Period to wait before removing the generating job | `30` | +| `jwtKeys.job.command` | Override the command generating the keys | `[]` | +| `jwtKeys.job.annotations` | Annotations to add to the generating job | `{}` | +| `jwtKeys.job.podAnnotations` | Annotations to add to the generating job Pod | `{}` | +| `jwtKeys.job.resources` | Resource requirements for the generating container | `{}` | +| `jwtKeys.job.nodeSelector` | Node selector for the generating job Pod | `{}` | +| `jwtKeys.job.tolerations` | Tolerations for the generating job Pod | `[]` | +| `jwtKeys.job.affinity` | Affinity for the generating job Pod | `{}` | +| `jwtKeys.job.serviceAccountName` | Optional service account name to use for the generating job Pod | `nil` | + +### yhub + +| Name | Description | Value | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `yhub.enabled` | Enable the yhub collaboration server, its service and its init-db job | `true` | +| `yhub.image.repository` | Repository to use to pull the yhub container image | `lasuite/impress-yhub` | +| `yhub.image.tag` | yhub container tag | `latest` | +| `yhub.image.pullPolicy` | yhub container image pull policy | `IfNotPresent` | +| `yhub.command` | Override the yhub container command | `[]` | +| `yhub.args` | Override the yhub container args | `[]` | +| `yhub.replicas` | Amount of yhub replicas | `3` | +| `yhub.shareProcessNamespace` | Enable share process namespace between containers | `false` | +| `yhub.sidecars` | Add sidecars containers to yhub deployment | `[]` | +| `yhub.terminationGracePeriodSeconds` | Grace period given to a yhub pod to drain before it is killed | `60` | +| `yhub.securityContext.allowPrivilegeEscalation` | Whether to allow privilege escalation for the yhub container | `false` | +| `yhub.securityContext.capabilities.drop` | List of capabilities to drop for the yhub container | `["ALL"]` | +| `yhub.securityContext.runAsNonRoot` | Whether to run the yhub container as a non-root user | `true` | +| `yhub.securityContext.seccompProfile.type` | Seccomp profile type for the yhub container | `RuntimeDefault` | +| `yhub.envVars` | Configure yhub container environment variables | `undefined` | +| `yhub.envVars.REDIS` | Required, redis/valkey url holding the live document state (e.g. redis://valkey:6379/0) | | +| `yhub.envVars.POSTGRES` | Required, url of the yhub database, created by the init-db job (e.g. postgres://user:pass@postgres:5432/yhub) | | +| `yhub.envVars.REDIS_PREFIX` | Namespace of the redis keys, when the instance is shared (default: yhub) | | +| `yhub.envVars.COLLABORATION_BACKEND_BASE_URL` | Base url of the Docs backend, which yhub asks about users and document access rights | | +| `yhub.envVars.COLLABORATION_SERVER_ORIGIN` | Comma separated list of the origins allowed to open a websocket | | +| `yhub.envVars.YHUB_JWT_PRIVATE_KEY_FILE` | Path to the RSA private key (PEM) yhub signs its calls to the backend with, mounted from a secret | | +| `yhub.envVars.SOFT_MIGRATION` | Set to "true" to seed rooms from the legacy Django/S3 document store on first access | | +| `yhub.envVars.BY_VALUE` | Example environment variable by setting value directly | | +| `yhub.envVars.FROM_CONFIGMAP.configMapKeyRef.name` | Name of a ConfigMap when configuring env vars from a ConfigMap | | +| `yhub.envVars.FROM_CONFIGMAP.configMapKeyRef.key` | Key within a ConfigMap when configuring env vars from a ConfigMap | | +| `yhub.envVars.FROM_SECRET.secretKeyRef.name` | Name of a Secret when configuring env vars from a Secret | | +| `yhub.envVars.FROM_SECRET.secretKeyRef.key` | Key within a Secret when configuring env vars from a Secret | | +| `yhub.podAnnotations` | Annotations to add to the yhub Pod | `{}` | +| `yhub.dpAnnotations` | Annotations to add to the yhub Deployment | `{}` | +| `yhub.initDbJobAnnotations` | Annotations for the yhub init-db job | `{}` | +| `yhub.jobs.ttlSecondsAfterFinished` | Period to wait before removing the init-db job | `30` | +| `yhub.jobs.backoffLimit` | Numbers of init-db job retries | `2` | +| `yhub.initDb.enabled` | Run the job creating and upgrading the yhub schema | `true` | +| `yhub.initDb.command` | Command creating and upgrading the yhub schema, what `npm run init-db` runs | `["node","node_modules/@y/hub/bin/init-db.js"]` | +| `yhub.initDb.restartPolicy` | Restart policy of the init-db job | `Never` | +| `yhub.service.type` | yhub Service type | `ClusterIP` | +| `yhub.service.port` | yhub Service listening port | `443` | +| `yhub.service.targetPort` | yhub container listening port | `3002` | +| `yhub.service.annotations` | Annotations to add to the yhub Service | `{}` | +| `yhub.probes.liveness.path` | Configure path for yhub HTTP liveness probe | `/collaboration/jwks/v1` | +| `yhub.probes.liveness.initialDelaySeconds` | Configure initial delay for yhub liveness probe | `10` | +| `yhub.probes.readiness.path` | Configure path for yhub HTTP readiness probe | `/collaboration/jwks/v1` | +| `yhub.probes.readiness.initialDelaySeconds` | Configure initial delay for yhub readiness probe | `5` | +| `yhub.probes.liveness.targetPort` | Configure port for yhub HTTP liveness probe | | +| `yhub.probes.liveness.timeoutSeconds` | Configure timeout for yhub liveness probe | | +| `yhub.probes.readiness.targetPort` | Configure port for yhub HTTP readiness probe | | +| `yhub.probes.readiness.timeoutSeconds` | Configure timeout for yhub readiness probe | | +| `yhub.probes.startup.path` | Configure path for yhub HTTP startup probe | | +| `yhub.probes.startup.targetPort` | Configure port for yhub HTTP startup probe | | +| `yhub.probes.startup.initialDelaySeconds` | Configure initial delay for yhub startup probe | | +| `yhub.probes.startup.timeoutSeconds` | Configure timeout for yhub startup probe | | +| `yhub.resources` | Resource requirements for the yhub container | `{}` | +| `yhub.nodeSelector` | Node selector for the yhub Pod | `{}` | +| `yhub.tolerations` | Tolerations for the yhub Pod | `[]` | +| `yhub.affinity` | Affinity for the yhub Pod | `{}` | +| `yhub.persistence` | Additional volumes to create and mount on the yhub. Used for debugging purposes | `{}` | +| `yhub.persistence.volume-name.size` | Size of the additional volume | | +| `yhub.persistence.volume-name.type` | Type of the additional volume, persistentVolumeClaim or emptyDir | | +| `yhub.persistence.volume-name.mountPath` | Path where the volume should be mounted to | | +| `yhub.extraVolumeMounts` | Additional volumes to mount on the yhub. Mounted on the init-db job too | `[]` | +| `yhub.extraVolumes` | Additional volumes to mount on the yhub. Mounted on the init-db job too | `[]` | +| `yhub.pdb.enabled` | Enable pdb on yhub | `true` | +| `yhub.serviceAccountName` | Optional service account name to use for yhub pods | `nil` | + ### docSpec | Name | Description | Value | | -------------------------------------------------- | --------------------------------------------------------------- | ----------------------- | | `docSpec.enabled` | Enable docSpec deployment | `false` | | `docSpec.image.repository` | Repository to use to pull docSpec container image | `ghcr.io/docspecio/api` | -| `docSpec.image.tag` | docSpec container tag | `2.6.3` | +| `docSpec.image.tag` | docSpec container tag | `3.0.1` | | `docSpec.image.pullPolicy` | docSpec container image pull policy | `IfNotPresent` | | `docSpec.command` | Override the docSpec container command | `[]` | | `docSpec.args` | Override the docSpec container args | `[]` | diff --git a/src/helm/impress/templates/_helpers.tpl b/src/helm/impress/templates/_helpers.tpl index c4fe19048..ea4544284 100644 --- a/src/helm/impress/templates/_helpers.tpl +++ b/src/helm/impress/templates/_helpers.tpl @@ -186,16 +186,6 @@ Requires top level scope {{ include "impress.fullname" . }}-y-provider {{- end }} -{{/* -Full name for the yProvider converter - -Requires top level scope -*/}} -{{- define "impress.yProvider.converter.fullname" -}} -{{ include "impress.yProvider.fullname" . }}-converter -{{- end }} - - {{/* Full name for the docSpec @@ -205,6 +195,104 @@ Requires top level scope {{ include "impress.fullname" . }}-docspec {{- end }} +{{/* +Full name for the yhub collaboration server + +Requires top level scope +*/}} +{{- define "impress.yhub.fullname" -}} +{{ include "impress.fullname" . }}-yhub +{{- end }} + +{{/* +JWT signing keys — the RSA keys the services sign the calls they make to each +other with. They are generated once by the jwt-keys job into a volume every +service mounts read-only, so no key is ever templated, stored in a values file +or read from the kubernetes API. + +Requires top level scope +*/}} +{{- define "impress.jwtKeys.claimName" -}} +{{- .Values.jwtKeys.persistence.existingClaim | default (printf "%s-jwt-keys" (include "impress.fullname" .)) -}} +{{- end }} + +{{- define "impress.jwtKeys.backendPath" -}} +{{ .Values.jwtKeys.mountPath }}/{{ .Values.jwtKeys.backendKeyFilename }} +{{- end }} + +{{- define "impress.jwtKeys.yhubPath" -}} +{{ .Values.jwtKeys.mountPath }}/{{ .Values.jwtKeys.yhubKeyFilename }} +{{- end }} + +{{/* +The volume holding the keys. readOnly on the volume itself, not only on the +mount: nothing but the generating job is allowed to write there. + +Requires top level scope +*/}} +{{- define "impress.jwtKeys.volume" -}} +- name: jwt-keys + persistentVolumeClaim: + claimName: {{ include "impress.jwtKeys.claimName" . }} + readOnly: true +{{- end }} + +{{- define "impress.jwtKeys.volumeMount" -}} +- name: jwt-keys + mountPath: {{ .Values.jwtKeys.mountPath }} + readOnly: true +{{- end }} + +{{/* +Init container waiting for the keys to be there. The generating job runs in an +earlier sync wave, but nothing orders the two under a plain `helm install` — +waiting here beats crash-looping, and it fails loudly (Init state) rather than +silently when the job never ran. + +Usage: {{ include "impress.jwtKeys.initContainer" (dict "root" $ "image" $image "pullPolicy" $pullPolicy "files" (list "/data/jwt/private.pem")) }} +*/}} +{{- define "impress.jwtKeys.initContainer" -}} +{{- $root := .root -}} +- name: wait-for-jwt-keys + image: {{ .image | quote }} + imagePullPolicy: {{ .pullPolicy }} + command: + - /bin/sh + - -c + - | + until {{ range $index, $file := .files }}{{ if $index }} && {{ end }}[ -r "{{ $file }}" ]{{ end }}; do + echo "waiting for the JWT signing keys to be generated..." + sleep 2 + done + {{- with $root.Values.jwtKeys.securityContext }} + securityContext: + {{- toYaml . | nindent 4 }} + {{- end }} + volumeMounts: + {{- include "impress.jwtKeys.volumeMount" $root | nindent 4 }} +{{- end }} + +{{/* +`*_FILE` environment variables pointing at the keys, added only when the +deployment did not set them by hand — configuring a key of your own stays +possible, and wins. + +Requires top level scope +*/}} +{{- define "impress.jwtKeys.backendEnv" -}} +{{- if not (hasKey (.Values.backend.envVars | default dict) "JWT_PRIVATE_KEY_FILE") }} +- name: "JWT_PRIVATE_KEY_FILE" + value: {{ include "impress.jwtKeys.backendPath" . | quote }} +{{- end }} +{{- end }} + +{{- define "impress.jwtKeys.yhubEnv" -}} +{{- if not (hasKey (.Values.yhub.envVars | default dict) "YHUB_JWT_PRIVATE_KEY_FILE") }} +- name: "YHUB_JWT_PRIVATE_KEY_FILE" + value: {{ include "impress.jwtKeys.yhubPath" . | quote }} +{{- end }} +{{- end }} + {{/* Full name for the Celery Worker diff --git a/src/helm/impress/templates/backend_cronjob_list.yaml b/src/helm/impress/templates/backend_cronjob_list.yaml index 10708c059..74ec45427 100644 --- a/src/helm/impress/templates/backend_cronjob_list.yaml +++ b/src/helm/impress/templates/backend_cronjob_list.yaml @@ -29,6 +29,15 @@ items: - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} {{- end}} shareProcessNamespace: {{ $.Values.backend.shareProcessNamespace }} + {{- if $.Values.jwtKeys.enabled }} + initContainers: + {{- include "impress.jwtKeys.initContainer" (dict + "root" $ + "image" (printf "%s:%s" (($.Values.backend.image | default dict).repository | default $.Values.image.repository) (($.Values.backend.image | default dict).tag | default $.Values.image.tag)) + "pullPolicy" (($.Values.backend.image | default dict).pullPolicy | default $.Values.image.pullPolicy) + "files" (list (include "impress.jwtKeys.backendPath" $)) + ) | nindent 16 }} + {{- end }} containers: {{- with $.Values.backend.sidecars }} {{- toYaml . | nindent 18 }} @@ -38,9 +47,12 @@ items: imagePullPolicy: {{ ($.Values.backend.image | default dict).pullPolicy | default $.Values.image.pullPolicy }} args: {{- toYaml .command | nindent 22 }} - {{- if $envVars}} + {{- if or $envVars $.Values.jwtKeys.enabled }} env: {{- $envVars | indent 22 }} + {{- if $.Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.backendEnv" $ | nindent 22 }} + {{- end }} {{- end }} {{- if $.Values.backend.envFrom }} envFrom: @@ -55,6 +67,9 @@ items: {{- toYaml . | nindent 22 }} {{- end }} volumeMounts: + {{- if $.Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volumeMount" $ | nindent 20 }} + {{- end }} {{- range $index, $value := $.Values.mountFiles }} - name: "files-{{ $index }}" mountPath: {{ $value.path }} @@ -72,6 +87,9 @@ items: {{- end }} restartPolicy: {{ .restartPolicy | default "Never" }} volumes: + {{- if $.Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volume" $ | nindent 16 }} + {{- end }} {{- range $index, $value := $.Values.mountFiles }} - name: "files-{{ $index }}" configMap: diff --git a/src/helm/impress/templates/backend_deployment.yaml b/src/helm/impress/templates/backend_deployment.yaml index ec5961cc8..5664bcc54 100644 --- a/src/helm/impress/templates/backend_deployment.yaml +++ b/src/helm/impress/templates/backend_deployment.yaml @@ -34,6 +34,15 @@ spec: serviceAccountName: {{ .Values.backend.serviceAccountName }} {{- end }} shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + {{- if .Values.jwtKeys.enabled }} + initContainers: + {{- include "impress.jwtKeys.initContainer" (dict + "root" . + "image" (printf "%s:%s" ((.Values.backend.image | default dict).repository | default .Values.image.repository) ((.Values.backend.image | default dict).tag | default .Values.image.tag)) + "pullPolicy" ((.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy) + "files" (list (include "impress.jwtKeys.backendPath" .)) + ) | nindent 8 }} + {{- end }} containers: {{- with .Values.backend.sidecars }} {{- toYaml . | nindent 8 }} @@ -49,9 +58,12 @@ spec: args: {{- toYaml . | nindent 12 }} {{- end }} - {{- if $envVars}} + {{- if or $envVars .Values.jwtKeys.enabled }} env: {{- $envVars | indent 12 }} + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.backendEnv" . | nindent 12 }} + {{- end }} {{- end }} {{- $envFrom := concat (.Values.backend.envFrom | default list) ((.Values.backend.django | default dict).envFrom | default list) }} {{- if $envFrom }} @@ -83,6 +95,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} volumeMounts: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volumeMount" . | nindent 12 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" mountPath: {{ $value.path }} @@ -116,6 +131,9 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} volumes: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volume" . | nindent 8 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" configMap: diff --git a/src/helm/impress/templates/backend_job.yml b/src/helm/impress/templates/backend_job.yml index 397f4cfbe..a34163465 100644 --- a/src/helm/impress/templates/backend_job.yml +++ b/src/helm/impress/templates/backend_job.yml @@ -29,6 +29,15 @@ spec: - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} {{- end}} shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + {{- if .Values.jwtKeys.enabled }} + initContainers: + {{- include "impress.jwtKeys.initContainer" (dict + "root" . + "image" (printf "%s:%s" ((.Values.backend.image | default dict).repository | default .Values.image.repository) ((.Values.backend.image | default dict).tag | default .Values.image.tag)) + "pullPolicy" ((.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy) + "files" (list (include "impress.jwtKeys.backendPath" .)) + ) | nindent 8 }} + {{- end }} containers: {{- with .Values.backend.sidecars }} {{- toYaml . | nindent 8 }} @@ -44,9 +53,12 @@ spec: args: {{- toYaml . | nindent 12 }} {{- end }} - {{- if $envVars}} + {{- if or $envVars .Values.jwtKeys.enabled }} env: {{- $envVars | indent 12 }} + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.backendEnv" . | nindent 12 }} + {{- end }} {{- end }} {{- if .Values.backend.envFrom }} envFrom: @@ -61,6 +73,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} volumeMounts: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volumeMount" . | nindent 12 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" mountPath: {{ $value.path }} @@ -90,6 +105,9 @@ spec: {{- end }} restartPolicy: {{ .Values.backend.job.restartPolicy }} volumes: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volume" . | nindent 8 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" configMap: diff --git a/src/helm/impress/templates/backend_job_createsuperuser.yaml b/src/helm/impress/templates/backend_job_createsuperuser.yaml index 76c230ce0..d4e43fde0 100644 --- a/src/helm/impress/templates/backend_job_createsuperuser.yaml +++ b/src/helm/impress/templates/backend_job_createsuperuser.yaml @@ -33,6 +33,15 @@ spec: serviceAccountName: {{ .Values.backend.serviceAccountName }} {{- end }} shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + {{- if .Values.jwtKeys.enabled }} + initContainers: + {{- include "impress.jwtKeys.initContainer" (dict + "root" . + "image" (printf "%s:%s" ((.Values.backend.image | default dict).repository | default .Values.image.repository) ((.Values.backend.image | default dict).tag | default .Values.image.tag)) + "pullPolicy" ((.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy) + "files" (list (include "impress.jwtKeys.backendPath" .)) + ) | nindent 8 }} + {{- end }} containers: {{- with .Values.backend.sidecars }} {{- toYaml . | nindent 8 }} @@ -48,9 +57,12 @@ spec: args: {{- toYaml . | nindent 12 }} {{- end }} - {{- if $envVars}} + {{- if or $envVars .Values.jwtKeys.enabled }} env: {{- $envVars | indent 12 }} + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.backendEnv" . | nindent 12 }} + {{- end }} {{- end }} {{- if .Values.backend.envFrom }} envFrom: @@ -65,6 +77,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} volumeMounts: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volumeMount" . | nindent 12 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" mountPath: {{ $value.path }} @@ -94,6 +109,9 @@ spec: {{- end }} restartPolicy: {{ .Values.backend.createsuperuser.restartPolicy }} volumes: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volume" . | nindent 8 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" configMap: diff --git a/src/helm/impress/templates/backend_job_migrate.yaml b/src/helm/impress/templates/backend_job_migrate.yaml index 28ce98978..e0dd52425 100644 --- a/src/helm/impress/templates/backend_job_migrate.yaml +++ b/src/helm/impress/templates/backend_job_migrate.yaml @@ -33,6 +33,15 @@ spec: serviceAccountName: {{ .Values.backend.serviceAccountName }} {{- end }} shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + {{- if .Values.jwtKeys.enabled }} + initContainers: + {{- include "impress.jwtKeys.initContainer" (dict + "root" . + "image" (printf "%s:%s" ((.Values.backend.image | default dict).repository | default .Values.image.repository) ((.Values.backend.image | default dict).tag | default .Values.image.tag)) + "pullPolicy" ((.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy) + "files" (list (include "impress.jwtKeys.backendPath" .)) + ) | nindent 8 }} + {{- end }} containers: {{- with .Values.backend.sidecars }} {{- toYaml . | nindent 8 }} @@ -48,9 +57,12 @@ spec: args: {{- toYaml . | nindent 12 }} {{- end }} - {{- if $envVars}} + {{- if or $envVars .Values.jwtKeys.enabled }} env: {{- $envVars | indent 12 }} + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.backendEnv" . | nindent 12 }} + {{- end }} {{- end }} {{- if .Values.backend.envFrom }} envFrom: @@ -65,6 +77,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} volumeMounts: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volumeMount" . | nindent 12 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" mountPath: {{ $value.path }} @@ -94,6 +109,9 @@ spec: {{- end }} restartPolicy: {{ .Values.backend.migrate.restartPolicy }} volumes: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volume" . | nindent 8 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" configMap: diff --git a/src/helm/impress/templates/celery_worker_deployment.yaml b/src/helm/impress/templates/celery_worker_deployment.yaml index 7c9a5c831..a7c1ac2c1 100644 --- a/src/helm/impress/templates/celery_worker_deployment.yaml +++ b/src/helm/impress/templates/celery_worker_deployment.yaml @@ -34,6 +34,15 @@ spec: serviceAccountName: {{ .Values.backend.serviceAccountName }} {{- end }} shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + {{- if .Values.jwtKeys.enabled }} + initContainers: + {{- include "impress.jwtKeys.initContainer" (dict + "root" . + "image" (printf "%s:%s" ((.Values.backend.image | default dict).repository | default .Values.image.repository) ((.Values.backend.image | default dict).tag | default .Values.image.tag)) + "pullPolicy" ((.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy) + "files" (list (include "impress.jwtKeys.backendPath" .)) + ) | nindent 8 }} + {{- end }} containers: {{- with .Values.backend.sidecars }} {{- toYaml . | nindent 8 }} @@ -49,9 +58,12 @@ spec: args: {{- toYaml . | nindent 12 }} {{- end }} - {{- if $envVars}} + {{- if or $envVars .Values.jwtKeys.enabled }} env: {{- $envVars | indent 12 }} + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.backendEnv" . | nindent 12 }} + {{- end }} {{- end }} {{- $envFrom := concat (.Values.backend.envFrom | default list) (.Values.backend.celery.envFrom | default list) }} {{- if $envFrom }} @@ -83,6 +95,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} volumeMounts: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volumeMount" . | nindent 12 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" mountPath: {{ $value.path }} @@ -116,6 +131,9 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} volumes: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volume" . | nindent 8 }} + {{- end }} {{- range $index, $value := .Values.mountFiles }} - name: "files-{{ $index }}" configMap: diff --git a/src/helm/impress/templates/ingress_collaboration_api.yaml b/src/helm/impress/templates/ingress_collaboration_api.yaml index 30d632791..8da0493bb 100644 --- a/src/helm/impress/templates/ingress_collaboration_api.yaml +++ b/src/helm/impress/templates/ingress_collaboration_api.yaml @@ -46,20 +46,26 @@ spec: - host: {{ .Values.ingressCollaborationApi.host | quote }} http: paths: - - path: {{ .Values.ingressCollaborationApi.path | quote }} + {{- /* one rule per route: what is not listed here is not reachable + from the outside, which is how the backend-internal routes + (reset-connections, migrate, restore-ydoc, reset-ydoc) stay + in-cluster */}} + {{- range .Values.ingressCollaborationApi.paths | default (list .Values.ingressCollaborationApi.path) }} + - path: {{ . | quote }} {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} pathType: ImplementationSpecific {{- end }} backend: {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} service: - name: {{ include "impress.yProvider.fullname" . }} + name: {{ include "impress.yhub.fullname" $ }} port: - number: {{ .Values.yProvider.service.port }} + number: {{ $.Values.yhub.service.port }} {{- else }} - serviceName: {{ include "impress.yProvider.fullname" . }} - servicePort: {{ .Values.yProvider.service.port }} + serviceName: {{ include "impress.yhub.fullname" $ }} + servicePort: {{ $.Values.yhub.service.port }} {{- end }} + {{- end }} {{- with .Values.ingressCollaborationApi.customBackends }} {{- toYaml . | nindent 10 }} {{- end }} diff --git a/src/helm/impress/templates/ingress_collaboration_ws.yaml b/src/helm/impress/templates/ingress_collaboration_ws.yaml index 887f74dd7..bac92ced3 100644 --- a/src/helm/impress/templates/ingress_collaboration_ws.yaml +++ b/src/helm/impress/templates/ingress_collaboration_ws.yaml @@ -53,12 +53,12 @@ spec: backend: {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} service: - name: {{ include "impress.yProvider.fullname" . }} + name: {{ include "impress.yhub.fullname" . }} port: - number: {{ .Values.yProvider.service.port }} + number: {{ .Values.yhub.service.port }} {{- else }} - serviceName: {{ include "impress.yProvider.fullname" . }} - servicePort: {{ .Values.yProvider.service.port }} + serviceName: {{ include "impress.yhub.fullname" . }} + servicePort: {{ .Values.yhub.service.port }} {{- end }} {{- with .Values.ingressCollaborationWS.customBackends }} {{- toYaml . | nindent 10 }} diff --git a/src/helm/impress/templates/jwt_keys_job.yaml b/src/helm/impress/templates/jwt_keys_job.yaml new file mode 100644 index 000000000..9c43b3f2d --- /dev/null +++ b/src/helm/impress/templates/jwt_keys_job.yaml @@ -0,0 +1,124 @@ +{{- if .Values.jwtKeys.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- $component := "jwt-keys" -}} +# Generates the RSA keys the services sign the calls they make to each other +# with: one for the backend (the tokens Django issues to the collaboration +# server and the converter), one for the collaboration server (the calls it +# makes back to Django). Only the private halves are written — each service +# publishes the public half of its own key on its JWKS endpoint, where the +# other one reads it, so no key is ever copied from one side to the other. +# +# Idempotent, and deliberately so: an existing key is kept, which is what makes +# it safe to re-run on every sync. Rolling a key is deleting it from the volume +# and letting the next run generate a new one — both services follow, they pick +# the verification key by its "kid" and re-fetch the set when they meet a new +# one. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $fullName }}-jwt-keys + namespace: {{ .Release.Namespace | quote }} + annotations: + argocd.argoproj.io/sync-options: Replace=true,Force=true + # after the claim, before anything reading the keys + argocd.argoproj.io/sync-wave: "-2" + {{- with .Values.jwtKeys.job.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + ttlSecondsAfterFinished: {{ .Values.jwtKeys.job.ttlSecondsAfterFinished }} + backoffLimit: {{ .Values.jwtKeys.job.backoffLimit }} + template: + metadata: + {{- with .Values.jwtKeys.job.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + {{- if .Values.jwtKeys.job.serviceAccountName }} + serviceAccountName: {{ .Values.jwtKeys.job.serviceAccountName }} + {{- end }} + # the volume of a freshly provisioned claim belongs to root:root, and the + # container is run un-privileged below: without an fsGroup it has nowhere + # to write the keys + {{- with .Values.jwtKeys.job.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ .Values.jwtKeys.job.restartPolicy }} + containers: + - name: jwt-keys + image: "{{ (.Values.jwtKeys.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.jwtKeys.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.jwtKeys.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.jwtKeys.job.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if not .Values.jwtKeys.job.command }} + # `command` rather than `args`: the image entrypoint is openssl itself. + # The keys are the ones `bin/generate-jwt-private-key.sh` writes for + # the compose stack, same command — PKCS#8 RSA, the format both the + # backend and the collaboration server read. + command: + - /bin/sh + - -c + - | + set -eu + + for path in {{ include "impress.jwtKeys.backendPath" . | quote }} {{ include "impress.jwtKeys.yhubPath" . | quote }}; do + if [ -f "$path" ]; then + echo "$path already exists, keeping it" + continue + fi + openssl genpkey -algorithm RSA \ + -pkeyopt rsa_keygen_bits:{{ .Values.jwtKeys.keySize }} \ + -out "$path.tmp" + # read-only for everyone: the services read it under user ids + # of their own, and nothing is ever to write it again + chmod 444 "$path.tmp" + # a rename, so a reader never sees a half written key + mv "$path.tmp" "$path" + echo "$path generated" + done + {{- end }} + {{- with .Values.jwtKeys.job.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.jwtKeys.job.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.jwtKeys.job.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + # the one place the volume is mounted writable + - name: jwt-keys + mountPath: {{ .Values.jwtKeys.mountPath }} + {{- with .Values.jwtKeys.job.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.jwtKeys.job.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.jwtKeys.job.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: jwt-keys + persistentVolumeClaim: + claimName: {{ include "impress.jwtKeys.claimName" . }} +{{- end }} diff --git a/src/helm/impress/templates/jwt_keys_pvc.yaml b/src/helm/impress/templates/jwt_keys_pvc.yaml new file mode 100644 index 000000000..f567108d4 --- /dev/null +++ b/src/helm/impress/templates/jwt_keys_pvc.yaml @@ -0,0 +1,32 @@ +{{- if and .Values.jwtKeys.enabled (not .Values.jwtKeys.persistence.existingClaim) -}} +{{- $component := "jwt-keys" -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "impress.jwtKeys.claimName" . }} + namespace: {{ .Release.Namespace | quote }} + annotations: + # the keys outlive the release: uninstalling and installing again keeps the + # identity of the services rather than silently rolling both keys. Delete + # the claim by hand to roll them. + helm.sh/resource-policy: keep + # before everything else, the generating job included + argocd.argoproj.io/sync-wave: "-3" + {{- with .Values.jwtKeys.persistence.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + # every service mounts it read-only, so all but the generating job read it at + # the same time — on more than one node as soon as a deployment has replicas + # spread around, which is what makes ReadWriteMany the default here + accessModes: + {{- toYaml .Values.jwtKeys.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.jwtKeys.persistence.size | quote }} + {{- if .Values.jwtKeys.persistence.storageClass }} + storageClassName: {{ .Values.jwtKeys.persistence.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/src/helm/impress/templates/yhub_deployment.yaml b/src/helm/impress/templates/yhub_deployment.yaml new file mode 100644 index 000000000..8c44fc6e2 --- /dev/null +++ b/src/helm/impress/templates/yhub_deployment.yaml @@ -0,0 +1,187 @@ +{{- if .Values.yhub.enabled -}} +{{- $envVars := include "impress.common.env" (list . .Values.yhub) -}} +{{- $fullName := include "impress.yhub.fullname" . -}} +{{- $component := "yhub" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + annotations: + {{- with .Values.yhub.dpAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + replicas: {{ .Values.yhub.replicas }} + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.yhub.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + {{- if .Values.yhub.serviceAccountName }} + serviceAccountName: {{ .Values.yhub.serviceAccountName }} + {{- end }} + shareProcessNamespace: {{ .Values.yhub.shareProcessNamespace }} + # a websocket connection is dropped when the pod goes away, and the client + # reconnects to another one — but the updates it sent are only in redis + # until a worker persists them, so leave the embedded worker time to drain + terminationGracePeriodSeconds: {{ .Values.yhub.terminationGracePeriodSeconds }} + {{- if .Values.jwtKeys.enabled }} + initContainers: + {{- include "impress.jwtKeys.initContainer" (dict + "root" . + "image" (printf "%s:%s" ((.Values.yhub.image | default dict).repository | default .Values.image.repository) ((.Values.yhub.image | default dict).tag | default .Values.image.tag)) + "pullPolicy" ((.Values.yhub.image | default dict).pullPolicy | default .Values.image.pullPolicy) + "files" (list (include "impress.jwtKeys.yhubPath" .)) + ) | nindent 8 }} + {{- end }} + containers: + {{- with .Values.yhub.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.yhub.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.yhub.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.yhub.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.yhub.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.yhub.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if or $envVars .Values.jwtKeys.enabled }} + env: + {{- $envVars | indent 12 }} + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.yhubEnv" . | nindent 12 }} + {{- end }} + {{- end }} + {{- if .Values.yhub.envFrom }} + envFrom: + {{- toYaml .Values.yhub.envFrom | nindent 12 }} + {{- end }} + {{- with .Values.yhub.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.yhub.service.targetPort }} + protocol: TCP + {{- if .Values.yhub.probes.liveness }} + livenessProbe: + {{- include "impress.probes.abstract" (merge .Values.yhub.probes.liveness (dict "targetPort" .Values.yhub.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.yhub.probes.readiness }} + readinessProbe: + {{- include "impress.probes.abstract" (merge .Values.yhub.probes.readiness (dict "targetPort" .Values.yhub.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.yhub.probes.startup }} + startupProbe: + {{- include "impress.probes.abstract" (merge .Values.yhub.probes.startup (dict "targetPort" .Values.yhub.service.targetPort )) | nindent 12 }} + {{- end }} + {{- with .Values.yhub.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volumeMount" . | nindent 12 }} + {{- end }} + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.yhub.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.yhub.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.yhub.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.yhub.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.yhub.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- if .Values.jwtKeys.enabled }} + {{- include "impress.jwtKeys.volume" . | nindent 8 }} + {{- end }} + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.yhub.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.yhub.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .secret }} + secret: + {{ toYaml .secret | nindent 12 }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} +--- +{{ if .Values.yhub.pdb.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} +spec: + maxUnavailable: 1 + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} +{{ end }} +{{- end }} diff --git a/src/helm/impress/templates/yhub_job_init_db.yaml b/src/helm/impress/templates/yhub_job_init_db.yaml new file mode 100644 index 000000000..ee8677ce1 --- /dev/null +++ b/src/helm/impress/templates/yhub_job_init_db.yaml @@ -0,0 +1,124 @@ +{{- if and .Values.yhub.enabled .Values.yhub.initDb.enabled -}} +{{- $envVars := include "impress.common.env" (list . .Values.yhub) -}} +{{- $fullName := include "impress.yhub.fullname" . -}} +{{- $component := "yhub" -}} +# yhub never runs DDL from the server or the worker: the schema is created by +# the script it ships, which creates the database when it is missing and every +# table the installed version needs. It is idempotent, and it has to run again +# on every yhub upgrade that adds a table — the counterpart of the backend +# migrate job, hence the same Replace=true so a re-sync re-runs it. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $fullName }}-init-db + namespace: {{ .Release.Namespace | quote }} + annotations: + argocd.argoproj.io/sync-options: Replace=true,Force=true + # the server answers "relation ... does not exist" on every document read + # until this has run, so it goes in an earlier wave than the deployment + argocd.argoproj.io/sync-wave: "-1" + {{- with .Values.yhub.initDbJobAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + ttlSecondsAfterFinished: {{ .Values.yhub.jobs.ttlSecondsAfterFinished }} + backoffLimit: {{ .Values.yhub.jobs.backoffLimit }} + template: + metadata: + annotations: + {{- with .Values.yhub.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + {{- if .Values.yhub.serviceAccountName }} + serviceAccountName: {{ .Values.yhub.serviceAccountName }} + {{- end }} + shareProcessNamespace: {{ .Values.yhub.shareProcessNamespace }} + containers: + - name: {{ .Chart.Name }} + image: "{{ (.Values.yhub.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.yhub.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.yhub.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.yhub.initDb.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if $envVars}} + env: + {{- $envVars | indent 12 }} + {{- end }} + {{- if .Values.yhub.envFrom }} + envFrom: + {{- toYaml .Values.yhub.envFrom | nindent 12 }} + {{- end }} + {{- with .Values.yhub.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.yhub.initDb.resources | default .Values.yhub.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range .Values.yhub.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.yhub.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.yhub.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.yhub.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ .Values.yhub.initDb.restartPolicy }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range .Values.yhub.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .secret }} + secret: + {{ toYaml .secret | nindent 12 }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} +{{- end }} diff --git a/src/helm/impress/templates/yhub_svc.yaml b/src/helm/impress/templates/yhub_svc.yaml new file mode 100644 index 000000000..12f0577ec --- /dev/null +++ b/src/helm/impress/templates/yhub_svc.yaml @@ -0,0 +1,22 @@ +{{- if .Values.yhub.enabled -}} +{{- $fullName := include "impress.yhub.fullname" . -}} +{{- $component := "yhub" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.yhub.service.annotations | nindent 4 }} +spec: + type: {{ .Values.yhub.service.type }} + ports: + - port: {{ .Values.yhub.service.port }} + targetPort: {{ .Values.yhub.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} +{{- end }} diff --git a/src/helm/impress/templates/yprovider_deployment.yaml b/src/helm/impress/templates/yprovider_deployment.yaml index 953c088db..8baadf09a 100644 --- a/src/helm/impress/templates/yprovider_deployment.yaml +++ b/src/helm/impress/templates/yprovider_deployment.yaml @@ -7,7 +7,7 @@ metadata: name: {{ $fullName }} namespace: {{ .Release.Namespace | quote }} annotations: - {{- with .Values.backend.dpAnnotations }} + {{- with .Values.yProvider.dpAnnotations }} {{- toYaml . | nindent 4 }} {{- end }} labels: diff --git a/src/helm/impress/templates/yprovider_deployment_converter.yaml b/src/helm/impress/templates/yprovider_deployment_converter.yaml deleted file mode 100644 index 0eb3dbe3c..000000000 --- a/src/helm/impress/templates/yprovider_deployment_converter.yaml +++ /dev/null @@ -1,189 +0,0 @@ -{{ if .Values.yProvider.converter.enabled -}} -{{- $yProvider := .Values.yProvider -}} -{{- $converter := .Values.yProvider.converter -}} -{{- $service := mergeOverwrite (dict) (default dict $yProvider.service) (default dict $converter.service) -}} -{{- $image := mergeOverwrite (dict) (default dict $yProvider.image) (default dict $converter.image) -}} -{{- $probes := mergeOverwrite (dict) (default dict $yProvider.probes) (default dict $converter.probes) -}} -{{- $pdb := mergeOverwrite (dict) (default dict $yProvider.pdb) (default dict $converter.pdb) -}} -{{- $dpAnnotations := mergeOverwrite (dict) (default dict $yProvider.dpAnnotations) (default dict $converter.dpAnnotations) -}} -{{- $podAnnotations := mergeOverwrite (dict) (default dict $yProvider.podAnnotations) (default dict $converter.podAnnotations) -}} -{{- $replicas := default $yProvider.replicas $converter.replicas -}} -{{- $serviceAccountName := default $yProvider.serviceAccountName $converter.serviceAccountName -}} -{{- $shareProcessNamespace := default $yProvider.shareProcessNamespace $converter.shareProcessNamespace -}} -{{- $sidecars := default $yProvider.sidecars $converter.sidecars -}} -{{- $command := default $yProvider.command $converter.command -}} -{{- $args := default $yProvider.args $converter.args -}} -{{- $envFrom := default $yProvider.envFrom $converter.envFrom -}} -{{- $securityContext := mergeOverwrite (dict) (default dict $yProvider.securityContext) (default dict $converter.securityContext) -}} -{{- $resources := mergeOverwrite (dict) (default dict $yProvider.resources) (default dict $converter.resources) -}} -{{- $nodeSelector := mergeOverwrite (dict) (default dict $yProvider.nodeSelector) (default dict $converter.nodeSelector) -}} -{{- $affinity := mergeOverwrite (dict) (default dict $yProvider.affinity) (default dict $converter.affinity) -}} -{{- $tolerations := default $yProvider.tolerations $converter.tolerations -}} -{{- $persistence := mergeOverwrite (dict) (default dict $yProvider.persistence) (default dict $converter.persistence) -}} -{{- $extraVolumeMounts := default $yProvider.extraVolumeMounts $converter.extraVolumeMounts -}} -{{- $extraVolumes := default $yProvider.extraVolumes $converter.extraVolumes -}} -{{- $envVarsScope := dict "envVars" (mergeOverwrite (dict) (default dict $yProvider.envVars) (default dict $converter.envVars)) -}} -{{- $envVars := include "impress.common.env" (list . $envVarsScope) -}} -{{- $fullName := include "impress.yProvider.converter.fullname" . -}} -{{- $component := "yProvider-converter" -}} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ $fullName }} - namespace: {{ .Release.Namespace | quote }} - annotations: - {{- with $dpAnnotations }} - {{- toYaml . | nindent 4 }} - {{- end }} - labels: - {{- include "impress.common.labels" (list . $component) | nindent 4 }} -spec: - replicas: {{ $replicas }} - selector: - matchLabels: - {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} - template: - metadata: - annotations: - {{- with $podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} - spec: - {{- if $.Values.image.credentials }} - imagePullSecrets: - - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} - {{- end}} - {{- if $serviceAccountName }} - serviceAccountName: {{ $serviceAccountName }} - {{- end }} - shareProcessNamespace: {{ $shareProcessNamespace }} - containers: - {{- with $sidecars }} - {{- toYaml . | nindent 8 }} - {{- end }} - - name: {{ .Chart.Name }} - image: "{{ $image.repository | default $.Values.image.repository }}:{{ $image.tag | default $.Values.image.tag }}" - imagePullPolicy: {{ $image.pullPolicy | default $.Values.image.pullPolicy }} - {{- with $command }} - command: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with $args }} - args: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- if $envVars}} - env: - {{- $envVars | indent 12 }} - {{- end }} - {{- if $envFrom }} - envFrom: - {{- toYaml $envFrom | nindent 12 }} - {{- end }} - {{- with $securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - ports: - - name: http - containerPort: {{ $service.targetPort }} - protocol: TCP - {{- if $probes.liveness }} - livenessProbe: - {{- include "impress.probes.abstract" (merge $probes.liveness (dict "targetPort" $service.targetPort )) | nindent 12 }} - {{- end }} - {{- if $probes.readiness }} - readinessProbe: - {{- include "impress.probes.abstract" (merge $probes.readiness (dict "targetPort" $service.targetPort )) | nindent 12 }} - {{- end }} - {{- if $probes.startup }} - startupProbe: - {{- include "impress.probes.abstract" (merge $probes.startup (dict "targetPort" $service.targetPort )) | nindent 12 }} - {{- end }} - {{- with $resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - volumeMounts: - {{- range $index, $value := .Values.mountFiles }} - - name: "files-{{ $index }}" - mountPath: {{ $value.path }} - subPath: content - {{- end }} - {{- range $name, $volume := $persistence }} - - name: "{{ $name }}" - mountPath: "{{ $volume.mountPath }}" - {{- end }} - {{- range $extraVolumeMounts }} - - name: {{ .name }} - mountPath: {{ .mountPath }} - subPath: {{ .subPath | default "" }} - readOnly: {{ .readOnly }} - {{- end }} - {{- with $nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with $affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with $tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - volumes: - {{- range $index, $value := .Values.mountFiles }} - - name: "files-{{ $index }}" - configMap: - name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" - {{- end }} - {{- range $name, $volume := $persistence }} - - name: "{{ $name }}" - {{- if eq $volume.type "emptyDir" }} - emptyDir: {} - {{- else }} - persistentVolumeClaim: - claimName: "{{ $fullName }}-{{ $name }}" - {{- end }} - {{- end }} - {{- range $extraVolumes }} - - name: {{ .name }} - {{- if .existingClaim }} - persistentVolumeClaim: - claimName: {{ .existingClaim }} - {{- else if .secret }} - secret: - {{ toYaml .secret | nindent 12 }} - {{- else if .hostPath }} - hostPath: - {{ toYaml .hostPath | nindent 12 }} - {{- else if .csi }} - csi: - {{- toYaml .csi | nindent 12 }} - {{- else if .configMap }} - configMap: - {{- toYaml .configMap | nindent 12 }} - {{- else if .emptyDir }} - emptyDir: - {{- toYaml .emptyDir | nindent 12 }} - {{- else }} - emptyDir: {} - {{- end }} - {{- end }} ---- -{{ if $pdb.enabled }} -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: {{ $fullName }} - namespace: {{ .Release.Namespace | quote }} -spec: - maxUnavailable: 1 - selector: - matchLabels: - {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} -{{ end }} -{{ end }} diff --git a/src/helm/impress/templates/yprovider_svc_converter.yaml b/src/helm/impress/templates/yprovider_svc_converter.yaml deleted file mode 100644 index 45cd1e7bf..000000000 --- a/src/helm/impress/templates/yprovider_svc_converter.yaml +++ /dev/null @@ -1,25 +0,0 @@ -{{ if .Values.yProvider.converter.enabled -}} -{{- $yProvider := .Values.yProvider -}} -{{- $converter := .Values.yProvider.converter -}} -{{- $service := mergeOverwrite (dict) (default dict $yProvider.service) (default dict $converter.service) -}} -{{- $fullName := include "impress.yProvider.converter.fullname" . -}} -{{- $component := "yProvider-converter" -}} -apiVersion: v1 -kind: Service -metadata: - name: {{ $fullName }} - namespace: {{ .Release.Namespace | quote }} - labels: - {{- include "impress.common.labels" (list . $component) | nindent 4 }} - annotations: - {{- toYaml $service.annotations | nindent 4 }} -spec: - type: {{ $service.type }} - ports: - - port: {{ $service.port }} - targetPort: {{ $service.targetPort }} - protocol: TCP - name: http - selector: - {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} -{{ end -}} diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index a64084647..d8e049b14 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -78,12 +78,14 @@ ingressCollaborationWS: ## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/enable-websocket ## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/proxy-read-timeout ## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/proxy-send-timeout - ## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/upstream-hash-by + ## + ## No upstream-hash-by: yhub passes updates between its replicas through + ## redis, so two clients editing the same document may land on different + ## pods — where the y-provider it replaces needed a room to stay on one. annotations: nginx.ingress.kubernetes.io/enable-websocket: "true" nginx.ingress.kubernetes.io/proxy-read-timeout: "86400" nginx.ingress.kubernetes.io/proxy-send-timeout: "86400" - nginx.ingress.kubernetes.io/upstream-hash-by: $arg_room ## @param ingressRedirects.enabled whether to enable the Ingress Redirects or not ## @param ingressRedirects.className IngressClass to use for the Ingress Redirects @@ -112,7 +114,22 @@ ingressCollaborationApi: enabled: false className: null host: impress.example.com + ## Only used when `paths` below is empty path: /collaboration/api/ + ## @param ingressCollaborationApi.paths Paths to route to the collaboration server, one rule each + ## + ## The routes yhub serves to browsers, guarded by the same document + ## authorization as the websocket. Everything it serves that is not listed + ## here stays in-cluster — `create-ydoc`, `reset-connections`, `migrate`, + ## `restore-ydoc` and `reset-ydoc` are called by the backend only, and + ## publishing them would put document deletion and the legacy migration one + ## request away from the internet. + ## + ## `jwks` is public on purpose: it carries the public halves of the keys + ## yhub signs with, and nothing else. + paths: + - /collaboration/ydoc/ + - /collaboration/jwks/ ## @param ingressCollaborationApi.hosts Additional host to configure for the Ingress hosts: [] # - chart-example.local @@ -129,9 +146,10 @@ ingressCollaborationApi: ## @param ingressCollaborationApi.customBackends Add custom backends to ingress customBackends: [] - ## @param ingressCollaborationApi.annotations.nginx.ingress.kubernetes.io/upstream-hash-by - annotations: - nginx.ingress.kubernetes.io/upstream-hash-by: $arg_room + ## @skip ingressCollaborationApi.annotations + ## Same as ingressCollaborationWS: no upstream-hash-by, any yhub replica + ## answers for any document. + annotations: {} ## @param ingressAdmin.enabled whether to enable the Ingress or not ## @param ingressAdmin.className IngressClass to use for the Ingress @@ -637,7 +655,11 @@ posthog: annotations: {} ## @section yProvider - +## +## The conversion service, and nothing else since the collaboration moved to +## yhub: this deployment *is* the converter the backend calls on +## `Y_PROVIDER_API_BASE_URL`, so there is no separate converter release to +## enable anymore. yProvider: ## @param yProvider.image.repository Repository to use to pull impress's yProvider container image ## @param yProvider.image.tag impress's yProvider container tag @@ -647,77 +669,6 @@ yProvider: pullPolicy: IfNotPresent tag: "latest" - converter: - ## @param yProvider.converter.enabled Enable the yProvider converter deployment and service - enabled: false - - ## @param yProvider.converter.replicas Amount of yProvider replicas - replicas: 3 - - ## @param yProvider.converter.resources Resource requirements for the yProvider container - resources: {} - - ## @param yProvider.converter.service.type yProvider converter Service type - ## @param yProvider.converter.service.port yProvider converter Service listening port - ## @param yProvider.converter.service.targetPort yProvider converter container listening port - ## @param yProvider.converter.service.annotations Annotations to add to the yProvider converter Service - service: {} - - ## @param yProvider.converter.command Override the yProvider converter container command - command: [] - - ## @param yProvider.converter.args Override the yProvider converter container args - args: [] - - ## @param yProvider.converter.shareProcessNamespace Enable share process namespace between containers - shareProcessNamespace: false - - ## @param yProvider.converter.sidecars Add sidecars containers to yProvider converter deployment - sidecars: [] - - ## @skip yProvider.converter.securityContext - securityContext: {} - - ## @skip yProvider.converter.envVars - envVars: {} - - ## @skip yProvider.converter.envFrom - envFrom: [] - - ## @param yProvider.converter.podAnnotations Annotations to add to the yProvider converter Pod - podAnnotations: {} - - ## @param yProvider.converter.dpAnnotations Annotations to add to the yProvider converter Deployment - dpAnnotations: {} - - ## @skip yProvider.converter.probes - probes: {} - - ## @param yProvider.converter.nodeSelector Node selector for the yProvider converter Pod - nodeSelector: {} - - ## @param yProvider.converter.tolerations Tolerations for the yProvider converter Pod - tolerations: [] - - ## @param yProvider.converter.affinity Affinity for the yProvider converter Pod - affinity: {} - - ## @param yProvider.converter.persistence Additional volumes to create and mount on the yProvider converter - persistence: {} - - ## @param yProvider.converter.extraVolumeMounts Additional volumes to mount on the yProvider converter - extraVolumeMounts: [] - - ## @param yProvider.converter.extraVolumes Additional volumes to mount on the yProvider converter - extraVolumes: [] - - ## @param yProvider.converter.pdb.enabled Enable pdb on yProvider converter - pdb: - enabled: true - - ## @param yProvider.converter.serviceAccountName Optional service account name to use for yProvider converter pods - serviceAccountName: null - ## @param yProvider.command Override the yProvider container command command: [] @@ -828,6 +779,302 @@ yProvider: ## @param yProvider.serviceAccountName Optional service account name to use for yProvider pods serviceAccountName: null +## @section JWT signing keys +## +## The services do not share a secret: each signs the calls it makes to the +## others with an RSA key of its own and publishes the public half on its JWKS +## endpoint, where the others read it. Enabling this generates those keys on +## the cluster — a job writes them once into a volume every service mounts +## read-only, and keeps them on the next run — and points the backend and the +## collaboration server at them. Nothing is templated into a manifest, stored +## in a values file or read back from the kubernetes API. +## +## The volume is mounted by every backend pod and by the collaboration server, +## which are spread over several nodes as soon as they have replicas: the +## storage class backing it must support ReadWriteMany. On a single node +## cluster, `accessModes: [ReadWriteOnce]` does just as well. +## +## Leave it disabled to keep providing the keys yourself, through +## `backend.envVars.JWT_PRIVATE_KEY_FILE` and +## `yhub.envVars.YHUB_JWT_PRIVATE_KEY_FILE` and volumes of your own — both are +## left untouched when they are set by hand, enabled or not. +jwtKeys: + ## @param jwtKeys.enabled Generate the JWT signing keys of the services on the cluster + enabled: false + + ## @param jwtKeys.mountPath Path the keys are mounted at, in every service reading them + mountPath: /data/jwt + + ## @param jwtKeys.backendKeyFilename Name of the key signing the tokens the backend issues + backendKeyFilename: private.pem + + ## @param jwtKeys.yhubKeyFilename Name of the key signing the calls the collaboration server makes to the backend + yhubKeyFilename: yhub-private.pem + + ## @param jwtKeys.keySize Size, in bits, of the generated RSA keys + keySize: 2048 + + ## @param jwtKeys.image.repository Repository to use to pull the image generating the keys + ## @param jwtKeys.image.tag Tag of the image generating the keys + ## @param jwtKeys.image.pullPolicy Pull policy of the image generating the keys + ## + ## openssl and a shell, nothing else. Its entrypoint is openssl itself, which + ## the job replaces by the script generating both keys. + image: + repository: alpine/openssl + pullPolicy: IfNotPresent + tag: "3.5.7" + + ## @param jwtKeys.securityContext.allowPrivilegeEscalation Whether to allow privilege escalation for the containers waiting for the keys + ## @param jwtKeys.securityContext.capabilities.drop List of capabilities to drop for the containers waiting for the keys + ## @param jwtKeys.securityContext.runAsNonRoot Whether to run the containers waiting for the keys as a non-root user + ## @param jwtKeys.securityContext.seccompProfile.type Seccomp profile type for the containers waiting for the keys + ## + ## Applies to the init containers waiting for the keys, which run the image of + ## the service they belong to, under the user that image declares. + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + + ## @param jwtKeys.persistence.existingClaim Claim holding the keys, generated in a claim of the chart's own when empty + ## @param jwtKeys.persistence.size Size of the claim holding the keys, two RSA keys and nothing else + ## @param jwtKeys.persistence.storageClass Storage class of the claim holding the keys, the cluster default when empty + ## @param jwtKeys.persistence.accessModes Access modes of the claim holding the keys + ## @param jwtKeys.persistence.annotations Annotations to add to the claim holding the keys + persistence: + existingClaim: null + size: 8Mi + storageClass: null + accessModes: + - ReadWriteMany + annotations: {} + + ## @param jwtKeys.job.podSecurityContext.fsGroup Group given ownership of the volume, so the generating container can write the keys in it + ## @param jwtKeys.job.securityContext.allowPrivilegeEscalation Whether to allow privilege escalation for the generating container + ## @param jwtKeys.job.securityContext.capabilities.drop List of capabilities to drop for the generating container + ## @param jwtKeys.job.securityContext.runAsNonRoot Whether to run the generating container as a non-root user + ## @param jwtKeys.job.securityContext.runAsUser User the generating container runs as, the openssl image declaring none + ## @param jwtKeys.job.securityContext.runAsGroup Group the generating container runs as + ## @param jwtKeys.job.securityContext.seccompProfile.type Seccomp profile type for the generating container + ## @param jwtKeys.job.restartPolicy Restart policy of the generating job + ## @param jwtKeys.job.backoffLimit Numbers of generating job retries + ## @param jwtKeys.job.ttlSecondsAfterFinished Period to wait before removing the generating job + ## @param jwtKeys.job.command Override the command generating the keys + ## @param jwtKeys.job.annotations Annotations to add to the generating job + ## @param jwtKeys.job.podAnnotations Annotations to add to the generating job Pod + ## @param jwtKeys.job.resources Resource requirements for the generating container + ## @param jwtKeys.job.nodeSelector Node selector for the generating job Pod + ## @param jwtKeys.job.tolerations Tolerations for the generating job Pod + ## @param jwtKeys.job.affinity Affinity for the generating job Pod + ## @param jwtKeys.job.serviceAccountName Optional service account name to use for the generating job Pod + ## @skip jwtKeys.job.env + job: + # the volume of a freshly provisioned claim belongs to root:root, so the + # group below is what lets the un-privileged user underneath write the keys + podSecurityContext: + fsGroup: 1000 + # the openssl image runs as root and declares no user of its own, so this + # one is named here rather than inherited — kubernetes refuses to start a + # container asking for runAsNonRoot without knowing which user to run as + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + restartPolicy: Never + backoffLimit: 2 + ttlSecondsAfterFinished: 30 + command: [] + annotations: {} + podAnnotations: {} + resources: {} + nodeSelector: {} + tolerations: [] + affinity: {} + serviceAccountName: null + env: [] + +## @section yhub +## +## The collaboration server: it serves the whole /collaboration/ prefix, the +## websocket included, and replaces the y-provider on that role. It keeps the +## live state of a document in redis/valkey and persists it to its own +## PostgreSQL database, so it needs both — set `yhub.envVars.REDIS` and +## `yhub.envVars.POSTGRES`, there is nothing sensible to default them to. +## Disabling it sends the /collaboration/ ingresses back to the y-provider. +yhub: + ## @param yhub.enabled Enable the yhub collaboration server, its service and its init-db job + enabled: true + + ## @param yhub.image.repository Repository to use to pull the yhub container image + ## @param yhub.image.tag yhub container tag + ## @param yhub.image.pullPolicy yhub container image pull policy + image: + repository: lasuite/impress-yhub + pullPolicy: IfNotPresent + tag: "latest" + + ## @param yhub.command Override the yhub container command + command: [] + + ## @param yhub.args Override the yhub container args + args: [] + + ## @param yhub.replicas Amount of yhub replicas + ## Every replica also runs a worker (redis consumer groups hand each task to + ## one of them), and clients editing the same document need not land on the + ## same pod: updates travel through redis. + replicas: 3 + + ## @param yhub.shareProcessNamespace Enable share process namespace between containers + shareProcessNamespace: false + + ## @param yhub.sidecars Add sidecars containers to yhub deployment + sidecars: [] + + ## @param yhub.terminationGracePeriodSeconds Grace period given to a yhub pod to drain before it is killed + terminationGracePeriodSeconds: 60 + + ## @param yhub.securityContext.allowPrivilegeEscalation Whether to allow privilege escalation for the yhub container + ## @param yhub.securityContext.capabilities.drop List of capabilities to drop for the yhub container + ## @param yhub.securityContext.runAsNonRoot Whether to run the yhub container as a non-root user + ## @param yhub.securityContext.seccompProfile.type Seccomp profile type for the yhub container + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + + ## @param yhub.envVars Configure yhub container environment variables + ## @extra yhub.envVars.REDIS Required, redis/valkey url holding the live document state (e.g. redis://valkey:6379/0) + ## @extra yhub.envVars.POSTGRES Required, url of the yhub database, created by the init-db job (e.g. postgres://user:pass@postgres:5432/yhub) + ## @extra yhub.envVars.REDIS_PREFIX Namespace of the redis keys, when the instance is shared (default: yhub) + ## @extra yhub.envVars.COLLABORATION_BACKEND_BASE_URL Base url of the Docs backend, which yhub asks about users and document access rights + ## @extra yhub.envVars.COLLABORATION_SERVER_ORIGIN Comma separated list of the origins allowed to open a websocket + ## @extra yhub.envVars.YHUB_JWT_PRIVATE_KEY_FILE Path to the RSA private key (PEM) yhub signs its calls to the backend with, mounted from a secret + ## @extra yhub.envVars.SOFT_MIGRATION Set to "true" to seed rooms from the legacy Django/S3 document store on first access + ## @extra yhub.envVars.BY_VALUE Example environment variable by setting value directly + ## @extra yhub.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap + ## @extra yhub.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap + ## @extra yhub.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret + ## @extra yhub.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret + ## @skip yhub.envVars + envVars: + <<: *commonEnvVars + + ## @skip yhub.envFrom List of environment variables taken from Secrets or configMaps + envFrom: [] + # envFrom: + # - secret: + # name: super-secret-user-credentials + # - configMapRef: + # name: my-environment-variables + + ## @param yhub.podAnnotations Annotations to add to the yhub Pod + podAnnotations: {} + + ## @param yhub.dpAnnotations Annotations to add to the yhub Deployment + dpAnnotations: {} + + ## @param yhub.initDbJobAnnotations Annotations for the yhub init-db job + initDbJobAnnotations: {} + + ## @param yhub.jobs.ttlSecondsAfterFinished Period to wait before removing the init-db job + ## @param yhub.jobs.backoffLimit Numbers of init-db job retries + jobs: + ttlSecondsAfterFinished: 30 + backoffLimit: 2 + + ## @param yhub.initDb.enabled Run the job creating and upgrading the yhub schema + ## @param yhub.initDb.command Command creating and upgrading the yhub schema, what `npm run init-db` runs + ## @param yhub.initDb.restartPolicy Restart policy of the init-db job + ## @skip yhub.initDb.resources Resource requirements for the init-db container, defaults to yhub.resources + initDb: + enabled: true + command: + - "node" + - "node_modules/@y/hub/bin/init-db.js" + restartPolicy: Never + resources: {} + + ## @param yhub.service.type yhub Service type + ## @param yhub.service.port yhub Service listening port + ## @param yhub.service.targetPort yhub container listening port + ## @param yhub.service.annotations Annotations to add to the yhub Service + service: + type: ClusterIP + port: 443 + targetPort: 3002 + annotations: {} + + ## @param yhub.probes.liveness.path Configure path for yhub HTTP liveness probe + ## @param yhub.probes.liveness.initialDelaySeconds Configure initial delay for yhub liveness probe + ## @param yhub.probes.readiness.path Configure path for yhub HTTP readiness probe + ## @param yhub.probes.readiness.initialDelaySeconds Configure initial delay for yhub readiness probe + ## @extra yhub.probes.liveness.targetPort Configure port for yhub HTTP liveness probe + ## @extra yhub.probes.liveness.timeoutSeconds Configure timeout for yhub liveness probe + ## @extra yhub.probes.readiness.targetPort Configure port for yhub HTTP readiness probe + ## @extra yhub.probes.readiness.timeoutSeconds Configure timeout for yhub readiness probe + ## @extra yhub.probes.startup.path Configure path for yhub HTTP startup probe + ## @extra yhub.probes.startup.targetPort Configure port for yhub HTTP startup probe + ## @extra yhub.probes.startup.initialDelaySeconds Configure initial delay for yhub startup probe + ## @extra yhub.probes.startup.timeoutSeconds Configure timeout for yhub startup probe + ## + ## The JWKS route is the only one yhub serves unauthenticated: it answers 200 + ## with the public keys it signs its calls to the backend with (an empty set + ## when no key is configured), and reads neither redis nor postgres. + probes: + liveness: + path: /collaboration/jwks/v1 + initialDelaySeconds: 10 + readiness: + path: /collaboration/jwks/v1 + initialDelaySeconds: 5 + + ## @param yhub.resources Resource requirements for the yhub container + resources: {} + + ## @param yhub.nodeSelector Node selector for the yhub Pod + nodeSelector: {} + + ## @param yhub.tolerations Tolerations for the yhub Pod + tolerations: [] + + ## @param yhub.affinity Affinity for the yhub Pod + affinity: {} + + ## @param yhub.persistence Additional volumes to create and mount on the yhub. Used for debugging purposes + ## @extra yhub.persistence.volume-name.size Size of the additional volume + ## @extra yhub.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir + ## @extra yhub.persistence.volume-name.mountPath Path where the volume should be mounted to + persistence: {} + + ## @param yhub.extraVolumeMounts Additional volumes to mount on the yhub. Mounted on the init-db job too + extraVolumeMounts: [] + + ## @param yhub.extraVolumes Additional volumes to mount on the yhub. Mounted on the init-db job too + extraVolumes: [] + + ## @param yhub.pdb.enabled Enable pdb on yhub + pdb: + enabled: true + + ## @param yhub.serviceAccountName Optional service account name to use for yhub pods + serviceAccountName: null + ## @section docSpec docSpec: ## @param docSpec.enabled Enable docSpec deployment diff --git a/src/yhub-server/Dockerfile b/src/yhub-server/Dockerfile index 4d3180494..3cb8063b5 100644 --- a/src/yhub-server/Dockerfile +++ b/src/yhub-server/Dockerfile @@ -3,7 +3,9 @@ FROM node:22-trixie AS base WORKDIR /app -COPY package.json package-lock.json ./ +# built from the repository root, like every other image here — the entrypoint +# below lives outside this directory +COPY ./src/yhub-server/package.json ./src/yhub-server/package-lock.json ./ # ---- Development image ---- @@ -16,7 +18,7 @@ RUN npm ci # server.js, migration.js, env.js — glob so a new module cannot be forgotten. # compose bind-mounts the sources over /app on top of this copy, so an edit on # the host is seen immediately; the copy keeps the image usable on its own. -COPY *.js ./ +COPY ./src/yhub-server/*.js ./ EXPOSE 3002 @@ -32,8 +34,24 @@ FROM base AS yhub RUN npm ci --omit=dev -COPY *.js ./ +COPY ./src/yhub-server/*.js ./ EXPOSE 3002 +# Same entrypoint as the other services: it gives the container user an entry in +# /etc/passwd, which an arbitrary uid (kubernetes runAsUser) does not have. The +# group needs the same rights as the owner on /etc/passwd for it to write there. +COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint +RUN chmod g=u /etc/passwd + +# Un-privileged user running the application. The server writes nothing outside +# stdout, so it needs no home and no writable path. Defaulted, unlike the other +# images of this repository: the helm chart runs the pod with runAsNonRoot, and +# a build that forgot the argument would produce an image kubernetes refuses to +# start. +ARG DOCKER_USER=1000 +USER ${DOCKER_USER} + +ENTRYPOINT [ "/usr/local/bin/entrypoint" ] + CMD ["node", "server.js"] diff --git a/src/yhub-server/README.md b/src/yhub-server/README.md index 3b3e7d607..849816738 100644 --- a/src/yhub-server/README.md +++ b/src/yhub-server/README.md @@ -85,7 +85,15 @@ repository: `app crashed - waiting for file changes` and the next save starts the server again, - `yhub` — the production image: production dependencies only, `node - server.js`, sources baked in. + server.js`, sources baked in, and the un-privileged user and the entrypoint + the other services use (kubernetes runs the pod with `runAsNonRoot`). + +Both are built **from the repository root**, like every other image here — the +entrypoint they share lives outside this directory: + +``` +docker build -f src/yhub-server/Dockerfile --target yhub . +``` nodemon rather than node's own `--watch`: the latter watches inodes, so it stops seeing a file as soon as it is replaced by a rename — which is what `git