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 c152a45a5..74a57ce55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,24 @@ 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 generates the backend and the + collaboration server keys with `openssl`, hands them to a secret both mount + read-only, and sets the `*_FILE` variables pointing at them. No key is + templated into a manifest or kept in a values file, and the job is the only + thing granted a write: its role may create a secret and read whether that one + exists, nothing else, and the services never call the kubernetes API. + Idempotent — an existing secret is left alone, so it re-runs on every sync, + and rolling the keys is deleting the secret and letting the next run create + it again. `jwtKeys.existingSecret` points at keys of your own instead, and + skips both the job and its rights +- ✨(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 — next to the backend migrate job, retrying while the + postgres server does not answer, since nothing in the chart creates it. + 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 @@ -204,13 +222,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..68e8f35e5 100644 --- a/documentation/collaboration.md +++ b/documentation/collaboration.md @@ -1,12 +1,65 @@ # 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 creates the two keys, once, in a secret every service mounts read-only, and points the backend and yhub at them. It generates them with `openssl` in a pod-local volume and hands them to `kubectl create secret`, so they never touch a disk, a manifest or a values file. The secret is left alone when it is already there, so the job is safe to re-run — it runs on every sync — and rolling the keys is deleting the secret and letting the next run create it again. Both sides follow: they pick the verification key by its `kid` and fetch the set again when they meet one they do not know. + +The job is the only thing allowed near that secret: the chart gives it a service account whose role can `create` a secret and read whether that one exists, nothing more. The services never call the kubernetes API — they read a mounted file. The secret is not part of the release either, so uninstalling keeps the same identities; delete the secret to start over. + +Deployments already holding their keys in a secret of their own point the chart at it instead, and the job and its rights are not created at all: + +```yaml +jwtKeys: + enabled: true + existingSecret: my-jwt-keys # holding private.pem and yhub-private.pem +``` + +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 a4f06e27d..df87cc03a 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: @@ -159,11 +162,6 @@ frontend: runAsNonRoot: false yProvider: - - converter: - enabled: true - replicas: 2 - replicas: 1 image: @@ -192,6 +190,47 @@ 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 into a secret both mount +# read-only. +jwtKeys: + enabled: true + +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 @@ -218,7 +257,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 2579b985d..e2ff44aca 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: @@ -137,11 +140,6 @@ frontend: tag: *tag yProvider: - - converter: - enabled: true - replicas: 1 - replicas: 1 image: @@ -155,6 +153,30 @@ 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 into a secret both mount +# read-only. +jwtKeys: + enabled: true + +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 @@ -182,7 +204,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 5e8a97f56..568e971ea 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,122 @@ | `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.existingSecret` | Secret already holding the keys, generated in a secret of the chart's own when empty | `nil` | +| `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.rbac.create` | Create the service account and the role the job needs to create the secret | `true` | +| `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.kubectlImage.repository` | Repository to use to pull the image handing the keys to the secret | `dtzar/helm-kubectl` | +| `jwtKeys.kubectlImage.tag` | Tag of the image handing the keys to the secret | `3.16.2` | +| `jwtKeys.kubectlImage.pullPolicy` | Pull policy of the image handing the keys to the secret | `IfNotPresent` | +| `jwtKeys.job.podSecurityContext` | Pod security context of the generating job | `{}` | +| `jwtKeys.job.securityContext.allowPrivilegeEscalation` | Whether to allow privilege escalation for the job containers | `false` | +| `jwtKeys.job.securityContext.capabilities.drop` | List of capabilities to drop for the job containers | `["ALL"]` | +| `jwtKeys.job.securityContext.runAsNonRoot` | Whether to run the job containers as a non-root user | `true` | +| `jwtKeys.job.securityContext.runAsUser` | User the job containers run as, their images declaring none | `1000` | +| `jwtKeys.job.securityContext.runAsGroup` | Group the job containers run as | `1000` | +| `jwtKeys.job.securityContext.seccompProfile.type` | Seccomp profile type for the job containers | `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.generateCommand` | Override the command generating the keys | `[]` | +| `jwtKeys.job.publishCommand` | Override the command creating the secret from the generated 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 job containers | `{}` | +| `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` | Service account of the generating job Pod, the one created above when empty | `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.runAsUser` | User the yhub container runs as | `1000` | +| `yhub.securityContext.runAsGroup` | Group the yhub container runs as | `1000` | +| `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` | Override the command creating and upgrading the yhub schema | `[]` | +| `yhub.initDb.retries` | How many times the schema script is retried while the postgres server does not answer | `60` | +| `yhub.initDb.retryDelaySeconds` | Seconds between two attempts | `5` | +| `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..8f8424571 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,81 @@ 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. The jwt-keys job generates them once into a secret every service +mounts read-only, so no key is ever templated into a manifest, written in a +values file, or kept anywhere the services themselves can write. + +Requires top level scope +*/}} +{{- define "impress.jwtKeys.secretName" -}} +{{- .Values.jwtKeys.existingSecret | default (printf "%s-jwt-keys" (include "impress.fullname" .)) -}} +{{- end }} + +{{- define "impress.jwtKeys.serviceAccountName" -}} +{{- .Values.jwtKeys.job.serviceAccountName | 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. A pod referencing a secret that does not exist yet +stays in ContainerCreating and mounts it as soon as the job creates it, so +nothing else is needed to order the two. + +Requires top level scope +*/}} +{{- define "impress.jwtKeys.volume" -}} +- name: jwt-keys + secret: + secretName: {{ include "impress.jwtKeys.secretName" . }} + # read-only for everyone, as the files the job generates are + defaultMode: 0444 +{{- end }} + +{{- define "impress.jwtKeys.volumeMount" -}} +- name: jwt-keys + mountPath: {{ .Values.jwtKeys.mountPath }} + readOnly: true +{{- 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..dce8f7205 100644 --- a/src/helm/impress/templates/backend_cronjob_list.yaml +++ b/src/helm/impress/templates/backend_cronjob_list.yaml @@ -38,9 +38,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 +58,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 +78,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..41657ee2a 100644 --- a/src/helm/impress/templates/backend_deployment.yaml +++ b/src/helm/impress/templates/backend_deployment.yaml @@ -49,9 +49,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 +86,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 +122,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..59f4ad1c0 100644 --- a/src/helm/impress/templates/backend_job.yml +++ b/src/helm/impress/templates/backend_job.yml @@ -44,9 +44,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 +64,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 +96,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..d34624e50 100644 --- a/src/helm/impress/templates/backend_job_createsuperuser.yaml +++ b/src/helm/impress/templates/backend_job_createsuperuser.yaml @@ -48,9 +48,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 +68,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 +100,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..24cf06458 100644 --- a/src/helm/impress/templates/backend_job_migrate.yaml +++ b/src/helm/impress/templates/backend_job_migrate.yaml @@ -48,9 +48,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 +68,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 +100,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..93854459f 100644 --- a/src/helm/impress/templates/celery_worker_deployment.yaml +++ b/src/helm/impress/templates/celery_worker_deployment.yaml @@ -49,9 +49,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 +86,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 +122,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..296e26525 --- /dev/null +++ b/src/helm/impress/templates/jwt_keys_job.yaml @@ -0,0 +1,153 @@ +{{- if and .Values.jwtKeys.enabled (not .Values.jwtKeys.existingSecret) -}} +{{- $fullName := include "impress.fullname" . -}} +{{- $component := "jwt-keys" -}} +{{- $secretName := include "impress.jwtKeys.secretName" . -}} +# 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. +# +# Two steps, two images: openssl writes the keys in a volume the pod throws +# away, then kubectl hands them to the secret the services mount. They only +# ever exist in that pod and in the secret. +# +# Idempotent, and deliberately so: the secret is left alone when it is already +# there, which is what makes it safe to re-run on every sync. Rolling the keys +# is deleting the secret and letting the next run create it again — both +# services follow, they pick the verification key by its "kid" and re-fetch the +# set when they meet one they do not know. +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 service account it runs as, before anything mounting the secret + 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}} + serviceAccountName: {{ include "impress.jwtKeys.serviceAccountName" . }} + {{- with .Values.jwtKeys.job.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ .Values.jwtKeys.job.restartPolicy }} + initContainers: + - name: generate + image: "{{ .Values.jwtKeys.image.repository }}:{{ .Values.jwtKeys.image.tag }}" + imagePullPolicy: {{ .Values.jwtKeys.image.pullPolicy }} + {{- if .Values.jwtKeys.job.generateCommand }} + command: + {{- toYaml .Values.jwtKeys.job.generateCommand | nindent 12 }} + {{- else }} + # `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 name in {{ .Values.jwtKeys.backendKeyFilename | quote }} {{ .Values.jwtKeys.yhubKeyFilename | quote }}; do + openssl genpkey -algorithm RSA \ + -pkeyopt rsa_keygen_bits:{{ .Values.jwtKeys.keySize }} \ + -out "/keys/$name" + echo "$name generated" + done + {{- end }} + {{- with .Values.jwtKeys.job.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.jwtKeys.job.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: keys + mountPath: /keys + containers: + - name: publish + image: "{{ .Values.jwtKeys.kubectlImage.repository }}:{{ .Values.jwtKeys.kubectlImage.tag }}" + imagePullPolicy: {{ .Values.jwtKeys.kubectlImage.pullPolicy }} + {{- if .Values.jwtKeys.job.publishCommand }} + command: + {{- toYaml .Values.jwtKeys.job.publishCommand | nindent 12 }} + {{- else }} + command: + - /bin/sh + - -c + - | + set -eu + + # the keys generated above are dropped on the floor when the + # secret is already there: they are new ones, and replacing the + # live pair is a decision, never a side effect of a sync + if kubectl get secret {{ $secretName | quote }} >/dev/null 2>&1; then + echo "secret {{ $secretName }} already exists, keeping the keys it holds" + exit 0 + fi + + kubectl create secret generic {{ $secretName | quote }} \ + --from-file={{ .Values.jwtKeys.backendKeyFilename }}=/keys/{{ .Values.jwtKeys.backendKeyFilename }} \ + --from-file={{ .Values.jwtKeys.yhubKeyFilename }}=/keys/{{ .Values.jwtKeys.yhubKeyFilename }} + echo "secret {{ $secretName }} created" + {{- 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: + - name: keys + mountPath: /keys + readOnly: true + {{- 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: + # the keys live here for the lifetime of this pod and nowhere else + - name: keys + emptyDir: + medium: Memory +{{- end }} diff --git a/src/helm/impress/templates/jwt_keys_rbac.yaml b/src/helm/impress/templates/jwt_keys_rbac.yaml new file mode 100644 index 000000000..397b55f4c --- /dev/null +++ b/src/helm/impress/templates/jwt_keys_rbac.yaml @@ -0,0 +1,63 @@ +{{- if and .Values.jwtKeys.enabled .Values.jwtKeys.rbac.create (not .Values.jwtKeys.existingSecret) -}} +{{- $component := "jwt-keys" -}} +{{- $name := include "impress.jwtKeys.serviceAccountName" . -}} +# The generating job is the only thing in this release allowed to touch the +# secret holding the keys, and all it is allowed to do is read whether it +# exists and create it — not read its content back, not replace it, not delete +# it. The services themselves get the keys through a volume, so they need no +# access to the kubernetes API at all. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace | quote }} + annotations: + # The same wave as the job that runs under it. An earlier wave looks safer + # and is not: argocd only moves to the next wave once the current one is + # healthy, and a ServiceAccount has no health of its own to report. Within + # a wave it applies by kind, and accounts, roles and bindings all come + # before jobs — which is the ordering actually needed here. + argocd.argoproj.io/sync-wave: "-2" + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace | quote }} + annotations: + argocd.argoproj.io/sync-wave: "-2" + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +rules: + # creating cannot be restricted to a name, kubernetes has no such rule + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create"] + # reading can, and is restricted to the one secret — to its existence really, + # the job never looks at what it holds + - apiGroups: [""] + resources: ["secrets"] + resourceNames: + - {{ include "impress.jwtKeys.secretName" . | quote }} + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace | quote }} + annotations: + argocd.argoproj.io/sync-wave: "-2" + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ $name }} +subjects: + - kind: ServiceAccount + name: {{ $name }} + namespace: {{ .Release.Namespace | quote }} +{{- 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..8ca6772aa --- /dev/null +++ b/src/helm/impress/templates/yhub_deployment.yaml @@ -0,0 +1,178 @@ +{{- 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 }} + 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..aa3c63a13 --- /dev/null +++ b/src/helm/impress/templates/yhub_job_init_db.yaml @@ -0,0 +1,148 @@ +{{- 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. +# +# No sync wave, again like the backend migrate job: it runs in the default one, +# alongside it, and waits for its database the way that one waits for Django's. +# An earlier wave only moved it ahead of the postgres it needs. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $fullName }}-init-db + namespace: {{ .Release.Namespace | quote }} + annotations: + argocd.argoproj.io/sync-options: Replace=true,Force=true + {{- 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 }} + {{- if .Values.yhub.initDb.command }} + command: + {{- toYaml .Values.yhub.initDb.command | nindent 12 }} + {{- else }} + # The script yhub ships, wrapped as `npm run init-db`, retried until + # the postgres server answers: nothing here creates it, and a chart + # sync does not wait for whatever does. Retrying the whole script + # rather than probing the port first — it is idempotent, so a run + # against a database that is up but incomplete is a no-op, and no + # postgres client has to be present in the image to ask. + command: + - /bin/sh + - -c + - | + set -u + + attempt=1 + until node node_modules/@y/hub/bin/init-db.js; do + if [ "$attempt" -ge {{ .Values.yhub.initDb.retries }} ]; then + echo "database still unreachable after $attempt attempts, giving up" + exit 1 + fi + echo "database not ready, retrying in {{ .Values.yhub.initDb.retryDelaySeconds }}s ($attempt/{{ .Values.yhub.initDb.retries }})" + attempt=$((attempt + 1)) + sleep {{ .Values.yhub.initDb.retryDelaySeconds }} + done + {{- 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 25ec08736..95671541b 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 @@ -643,7 +661,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 @@ -653,77 +675,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: [] @@ -834,6 +785,310 @@ 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 creates them once in a secret every service mounts +## read-only, and leaves them alone on the next run — and points the backend +## and the collaboration server at them. No key is ever templated into a +## manifest or written in a values file, and only that job may create the +## secret: nothing in the release can read it back through the api. +## +## 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.existingSecret Secret already holding the keys, generated in a secret of the chart's own when empty + ## + ## It has to hold the two filenames below. Naming one skips the job and the + ## rights it needs, the services only mount what is there. + existingSecret: null + + ## @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.rbac.create Create the service account and the role the job needs to create the secret + ## + ## Turning it off means providing `jwtKeys.job.serviceAccountName` with an + ## account allowed to `create` secrets and to `get` the one named above. + rbac: + create: true + + ## @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.kubectlImage.repository Repository to use to pull the image handing the keys to the secret + ## @param jwtKeys.kubectlImage.tag Tag of the image handing the keys to the secret + ## @param jwtKeys.kubectlImage.pullPolicy Pull policy of the image handing the keys to the secret + ## + ## A second image because the openssl one carries no kubectl, and reaching + ## the api with what it does carry (busybox wget, which cannot be told about + ## the cluster ca) would mean sending the token over an unverified + ## connection. + kubectlImage: + repository: dtzar/helm-kubectl + pullPolicy: IfNotPresent + tag: "3.16.2" + + ## @param jwtKeys.job.podSecurityContext Pod security context of the generating job + ## @param jwtKeys.job.securityContext.allowPrivilegeEscalation Whether to allow privilege escalation for the job containers + ## @param jwtKeys.job.securityContext.capabilities.drop List of capabilities to drop for the job containers + ## @param jwtKeys.job.securityContext.runAsNonRoot Whether to run the job containers as a non-root user + ## @param jwtKeys.job.securityContext.runAsUser User the job containers run as, their images declaring none + ## @param jwtKeys.job.securityContext.runAsGroup Group the job containers run as + ## @param jwtKeys.job.securityContext.seccompProfile.type Seccomp profile type for the job containers + ## @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.generateCommand Override the command generating the keys + ## @param jwtKeys.job.publishCommand Override the command creating the secret from the generated 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 job containers + ## @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 Service account of the generating job Pod, the one created above when empty + ## @skip jwtKeys.job.env + job: + podSecurityContext: {} + # neither image declares a user of its own, and 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 + generateCommand: [] + publishCommand: [] + 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.runAsUser User the yhub container runs as + ## @param yhub.securityContext.runAsGroup Group the yhub container runs as + ## @param yhub.securityContext.seccompProfile.type Seccomp profile type for the yhub container + ## + ## The user is named rather than left to the image: asking for runAsNonRoot + ## without it is refused outright by kubernetes ("container has runAsNonRoot + ## and image will run as root") on any image that declares none — which every + ## yhub image built before the un-privileged user was added to its Dockerfile + ## does. 1000 is the `node` user the base image already carries. + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + 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 Override the command creating and upgrading the yhub schema + ## @param yhub.initDb.retries How many times the schema script is retried while the postgres server does not answer + ## @param yhub.initDb.retryDelaySeconds Seconds between two attempts + ## @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 + ## + ## The job runs in the default sync wave, next to the backend migrate job, + ## and waits for its database the same way that one waits for Django's: + ## nothing in this chart creates the postgres server, so it has to be given + ## the time whatever does takes. The defaults below wait five minutes. + initDb: + enabled: true + command: [] + retries: 60 + retryDelaySeconds: 5 + 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