From 70fce980c9a79acda813b2323abefc2bf5b1714e Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 18:43:26 +0200 Subject: [PATCH 01/14] feat: split the charts into eduide and eduide-cluster Three charts become two, along the line that actually matters: what is installed once per cluster, and what is installed once per environment. eduide-cluster CRDs, conversion webhook, ClusterRoles, cert-manager issuers. Was theia-cloud-base + theia-cloud-crds. eduide operator, service, landing page, routes. Was theia-cloud. Both carry the same version and are released together. Why the split. Every tenant deploy used to reinstall the cluster-scoped charts into the default namespace, so three concurrent test deploys raced over the same objects; that was worked around with a six-attempt retry loop. One owner removes the race instead of retrying through it, and a tenant upgrade can no longer touch a CRD and break the other environments on the same cluster. The conversion webhook moves to the cluster chart because a CRD names exactly one conversion service. As a tenant resource, "which of the four environments on this cluster serves CRD conversion" has no answer. Added: - eduide-cluster-version ConfigMap, so a tenant release can tell whether the cluster has been bootstrapped. Without it the first symptom of a missing bootstrap is the operator crash-looping on an absent CRD. - A preflight check in the tenant chart that fails with that message. Bypass with skipPreflight=true. - helm.sh/resource-policy: keep on the three CRDs. helm uninstall would otherwise delete every live Session, Workspace and AppDefinition on the cluster. - scripts/adopt-release.sh, which hands existing objects to a new release name by annotation instead of deleting and recreating them. Generalises the inline kubectl annotate hack that had grown into the deploy workflow. - docs/charts.md. Resource names are deliberately NOT release-prefixed. The operator mounts oauth2-proxy-config, oauth2-templates and oauth2-emails by literal name into every session pod (AddedHandlerUtil.java:88), so prefixing them would break every running session. One install is one namespace, so prefixing buys no collision protection anyway. Verified: - eduide-cluster renders the same resource set as the two charts it replaces, plus the version ConfigMap and nothing else. - All five environments render identically to origin/main once Helm's own "# Source:" provenance comments are ignored, which are the only lines the rename changes. - helm lint clean, kubeconform clean. kubeconform earned its place here: the first attempt at the resource-policy annotation inserted a second annotations key into CRD metadata that already had one, producing invalid YAML that helm lint accepted and helm template happily emitted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/release.yml | 10 +-- .../.helmignore | 0 charts/eduide-cluster/Chart.yaml | 8 ++ .../README.md | 11 ++- .../README.md.gotmpl | 0 .../templates/crds/appdefinition.yaml} | 3 + .../crds}/conversion-webhook-certificate.yaml | 0 .../crds}/conversion-webhook-deployment.yaml | 0 .../crds}/conversion-webhook-service.yaml | 0 .../templates/crds/session.yaml} | 3 + .../templates/crds/workspace.yaml} | 3 + .../issuers}/clusterissuer-for-ca.yaml | 0 .../issuers}/clusterissuer-production.yaml | 0 .../issuers}/clusterissuer-selfsigned.yaml | 0 .../issuers}/theia-cloud-ca-certificate.yaml | 0 .../templates/rbac/clusterrole-operator.yaml} | 0 .../templates/rbac/clusterrole-service.yaml} | 0 .../templates/version-configmap.yaml | 17 ++++ .../values.yaml | 19 ++++ .../{theia-cloud-crds => eduide}/.helmignore | 0 charts/{theia-cloud => eduide}/.project | 0 .../{theia-cloud-base => eduide}/Chart.yaml | 9 +- charts/{theia-cloud => eduide}/README.md | 6 +- .../README.md.gotmpl | 0 .../logos/cdtcloud.svg | 0 .../logos/theiablueprint.svg | 0 .../templates/_gateway-helpers.tpl | 0 .../templates/_helpers.tpl | 0 charts/eduide/templates/_preflight.tpl | 22 +++++ .../templates/gateway.yaml | 0 .../templates/httproute-instances.yaml | 0 .../templates/httproute-landing.yaml | 0 .../templates/httproute-service.yaml | 0 .../templates/image-preloading.yaml | 0 .../templates/landing-page-config-map.yaml | 0 .../templates/landing-page.yaml | 0 .../templates/oauth2-configmap-htmlpage.yaml | 0 ...oauth2-configmap-oauth2proxy-keycloak.yaml | 0 .../operator-api-service-account.yaml | 0 .../templates/operator-configmap-logging.yaml | 0 .../templates/operator-configmap.yaml | 0 .../templates/operator-gateway-role.yaml | 0 .../templates/operator-role.yaml | 0 .../templates/operator.yaml | 0 .../service-api-service-account.yaml | 0 .../templates/service-configmap.yaml | 0 .../templates/service-role.yaml | 0 .../templates/service.yaml | 0 .../templates/theia-appdefinition-spec.yaml | 0 charts/{theia-cloud => eduide}/values.yaml | 4 + charts/theia-cloud-crds/Chart.yaml | 24 ----- charts/theia-cloud-crds/README.md | 20 ----- charts/theia-cloud-crds/values.yaml | 10 --- charts/theia-cloud/.helmignore | 23 ----- charts/theia-cloud/Chart.yaml | 21 ----- charts/theia-cloud/README.md.gotmpl | 21 ----- docs/charts.md | 90 +++++++++++++++++++ scripts/adopt-release.sh | 41 +++++++++ scripts/render-envs.sh | 13 ++- 59 files changed, 239 insertions(+), 139 deletions(-) rename charts/{theia-cloud-base => eduide-cluster}/.helmignore (100%) create mode 100644 charts/eduide-cluster/Chart.yaml rename charts/{theia-cloud-base => eduide-cluster}/README.md (67%) rename charts/{theia-cloud-base => eduide-cluster}/README.md.gotmpl (100%) rename charts/{theia-cloud-crds/templates/appdefinition-spec-resource.yaml => eduide-cluster/templates/crds/appdefinition.yaml} (98%) rename charts/{theia-cloud-crds/templates => eduide-cluster/templates/crds}/conversion-webhook-certificate.yaml (100%) rename charts/{theia-cloud-crds/templates => eduide-cluster/templates/crds}/conversion-webhook-deployment.yaml (100%) rename charts/{theia-cloud-crds/templates => eduide-cluster/templates/crds}/conversion-webhook-service.yaml (100%) rename charts/{theia-cloud-crds/templates/session-spec-resource.yaml => eduide-cluster/templates/crds/session.yaml} (97%) rename charts/{theia-cloud-crds/templates/workspace-spec-resource.yaml => eduide-cluster/templates/crds/workspace.yaml} (97%) rename charts/{theia-cloud-base/templates => eduide-cluster/templates/issuers}/clusterissuer-for-ca.yaml (100%) rename charts/{theia-cloud-base/templates => eduide-cluster/templates/issuers}/clusterissuer-production.yaml (100%) rename charts/{theia-cloud-base/templates => eduide-cluster/templates/issuers}/clusterissuer-selfsigned.yaml (100%) rename charts/{theia-cloud-base/templates => eduide-cluster/templates/issuers}/theia-cloud-ca-certificate.yaml (100%) rename charts/{theia-cloud-base/templates/operator-role.yaml => eduide-cluster/templates/rbac/clusterrole-operator.yaml} (100%) rename charts/{theia-cloud-base/templates/service-role.yaml => eduide-cluster/templates/rbac/clusterrole-service.yaml} (100%) create mode 100644 charts/eduide-cluster/templates/version-configmap.yaml rename charts/{theia-cloud-base => eduide-cluster}/values.yaml (53%) rename charts/{theia-cloud-crds => eduide}/.helmignore (100%) rename charts/{theia-cloud => eduide}/.project (100%) rename charts/{theia-cloud-base => eduide}/Chart.yaml (85%) rename charts/{theia-cloud => eduide}/README.md (98%) rename charts/{theia-cloud-crds => eduide}/README.md.gotmpl (100%) rename charts/{theia-cloud => eduide}/logos/cdtcloud.svg (100%) rename charts/{theia-cloud => eduide}/logos/theiablueprint.svg (100%) rename charts/{theia-cloud => eduide}/templates/_gateway-helpers.tpl (100%) rename charts/{theia-cloud => eduide}/templates/_helpers.tpl (100%) create mode 100644 charts/eduide/templates/_preflight.tpl rename charts/{theia-cloud => eduide}/templates/gateway.yaml (100%) rename charts/{theia-cloud => eduide}/templates/httproute-instances.yaml (100%) rename charts/{theia-cloud => eduide}/templates/httproute-landing.yaml (100%) rename charts/{theia-cloud => eduide}/templates/httproute-service.yaml (100%) rename charts/{theia-cloud => eduide}/templates/image-preloading.yaml (100%) rename charts/{theia-cloud => eduide}/templates/landing-page-config-map.yaml (100%) rename charts/{theia-cloud => eduide}/templates/landing-page.yaml (100%) rename charts/{theia-cloud => eduide}/templates/oauth2-configmap-htmlpage.yaml (100%) rename charts/{theia-cloud => eduide}/templates/oauth2-configmap-oauth2proxy-keycloak.yaml (100%) rename charts/{theia-cloud => eduide}/templates/operator-api-service-account.yaml (100%) rename charts/{theia-cloud => eduide}/templates/operator-configmap-logging.yaml (100%) rename charts/{theia-cloud => eduide}/templates/operator-configmap.yaml (100%) rename charts/{theia-cloud => eduide}/templates/operator-gateway-role.yaml (100%) rename charts/{theia-cloud => eduide}/templates/operator-role.yaml (100%) rename charts/{theia-cloud => eduide}/templates/operator.yaml (100%) rename charts/{theia-cloud => eduide}/templates/service-api-service-account.yaml (100%) rename charts/{theia-cloud => eduide}/templates/service-configmap.yaml (100%) rename charts/{theia-cloud => eduide}/templates/service-role.yaml (100%) rename charts/{theia-cloud => eduide}/templates/service.yaml (100%) rename charts/{theia-cloud => eduide}/templates/theia-appdefinition-spec.yaml (100%) rename charts/{theia-cloud => eduide}/values.yaml (99%) delete mode 100644 charts/theia-cloud-crds/Chart.yaml delete mode 100644 charts/theia-cloud-crds/README.md delete mode 100644 charts/theia-cloud-crds/values.yaml delete mode 100644 charts/theia-cloud/.helmignore delete mode 100644 charts/theia-cloud/Chart.yaml delete mode 100644 charts/theia-cloud/README.md.gotmpl create mode 100644 docs/charts.md create mode 100755 scripts/adopt-release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c698b8..1d7c845 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,8 @@ jobs: mkdir -p dist charts=( - theia-cloud-base - theia-cloud-crds - theia-cloud + eduide-cluster + eduide ) for chart in "${charts[@]}"; do @@ -112,9 +111,8 @@ jobs: } charts=( - theia-cloud-base - theia-cloud-crds - theia-cloud + eduide-cluster + eduide ) for chart in "${charts[@]}"; do diff --git a/charts/theia-cloud-base/.helmignore b/charts/eduide-cluster/.helmignore similarity index 100% rename from charts/theia-cloud-base/.helmignore rename to charts/eduide-cluster/.helmignore diff --git a/charts/eduide-cluster/Chart.yaml b/charts/eduide-cluster/Chart.yaml new file mode 100644 index 0000000..ad02360 --- /dev/null +++ b/charts/eduide-cluster/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: eduide-cluster +description: | + Cluster-scoped half of EduIDE: CRDs, the conversion webhook, ClusterRoles and + cert-manager issuers. Install once per cluster, before any eduide release. +type: application +version: 1.0.0-rc0 +appVersion: "1.0.0-rc0" diff --git a/charts/theia-cloud-base/README.md b/charts/eduide-cluster/README.md similarity index 67% rename from charts/theia-cloud-base/README.md rename to charts/eduide-cluster/README.md index c65849a..c1b9815 100644 --- a/charts/theia-cloud-base/README.md +++ b/charts/eduide-cluster/README.md @@ -1,8 +1,9 @@ -# theia-cloud-base +# eduide-cluster -![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.4.0-next](https://img.shields.io/badge/AppVersion-1.4.0--next-informational?style=flat-square) +![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.0.0-rc0](https://img.shields.io/badge/AppVersion-1.0.0--rc0-informational?style=flat-square) -Theia-cloud base chart +Cluster-scoped half of EduIDE: CRDs, the conversion webhook, ClusterRoles and +cert-manager issuers. Install once per cluster, before any eduide release. *This chart was tested with Helm version v3.17.0.* *Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* @@ -12,6 +13,10 @@ Theia-cloud base chart | Key | Type | Default | Description | |-----|------|---------|-------------| | certmanager.namespace | string | `"cert-manager"` | the namespace where the cert-manager is installed | +| clusterIssuer | string | `"theia-cloud-selfsigned-issuer"` | The cluster issuer to use for the certificate | +| conversion.certMountPath | string | `"/etc/webhook/certs"` | The location of where the certificates are mounted into the container (needs to match with application.properties) | +| conversion.certReloadPeriod | int | `604800` | The certificate reload period in seconds | +| conversion.image | string | `"theiacloud/theia-cloud-conversion-webhook:1.2.0-next"` | The image of the webhook container | | issuer.email | string | `"mmorlock@example.com"` | email used to issue let's encrypt certificates | | issuerca.enable | bool | `true` | whether to install the CA certificate signer | | issuerca.name | string | `"theia-cloud-ca-certificate-signer"` | name for the issuer preparing a self signed CA certificate | diff --git a/charts/theia-cloud-base/README.md.gotmpl b/charts/eduide-cluster/README.md.gotmpl similarity index 100% rename from charts/theia-cloud-base/README.md.gotmpl rename to charts/eduide-cluster/README.md.gotmpl diff --git a/charts/theia-cloud-crds/templates/appdefinition-spec-resource.yaml b/charts/eduide-cluster/templates/crds/appdefinition.yaml similarity index 98% rename from charts/theia-cloud-crds/templates/appdefinition-spec-resource.yaml rename to charts/eduide-cluster/templates/crds/appdefinition.yaml index f439de9..3ca8941 100644 --- a/charts/theia-cloud-crds/templates/appdefinition-spec-resource.yaml +++ b/charts/eduide-cluster/templates/crds/appdefinition.yaml @@ -3,6 +3,9 @@ kind: CustomResourceDefinition metadata: name: appdefinitions.theia.cloud annotations: + # Never delete on uninstall: that would take every live Session, + # Workspace and AppDefinition on the cluster with it. + helm.sh/resource-policy: keep cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/conversion-webhook-certificate spec: group: theia.cloud diff --git a/charts/theia-cloud-crds/templates/conversion-webhook-certificate.yaml b/charts/eduide-cluster/templates/crds/conversion-webhook-certificate.yaml similarity index 100% rename from charts/theia-cloud-crds/templates/conversion-webhook-certificate.yaml rename to charts/eduide-cluster/templates/crds/conversion-webhook-certificate.yaml diff --git a/charts/theia-cloud-crds/templates/conversion-webhook-deployment.yaml b/charts/eduide-cluster/templates/crds/conversion-webhook-deployment.yaml similarity index 100% rename from charts/theia-cloud-crds/templates/conversion-webhook-deployment.yaml rename to charts/eduide-cluster/templates/crds/conversion-webhook-deployment.yaml diff --git a/charts/theia-cloud-crds/templates/conversion-webhook-service.yaml b/charts/eduide-cluster/templates/crds/conversion-webhook-service.yaml similarity index 100% rename from charts/theia-cloud-crds/templates/conversion-webhook-service.yaml rename to charts/eduide-cluster/templates/crds/conversion-webhook-service.yaml diff --git a/charts/theia-cloud-crds/templates/session-spec-resource.yaml b/charts/eduide-cluster/templates/crds/session.yaml similarity index 97% rename from charts/theia-cloud-crds/templates/session-spec-resource.yaml rename to charts/eduide-cluster/templates/crds/session.yaml index 3af7353..382caad 100644 --- a/charts/theia-cloud-crds/templates/session-spec-resource.yaml +++ b/charts/eduide-cluster/templates/crds/session.yaml @@ -3,6 +3,9 @@ kind: CustomResourceDefinition metadata: name: sessions.theia.cloud annotations: + # Never delete on uninstall: that would take every live Session, + # Workspace and AppDefinition on the cluster with it. + helm.sh/resource-policy: keep cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/conversion-webhook-certificate spec: group: theia.cloud diff --git a/charts/theia-cloud-crds/templates/workspace-spec-resource.yaml b/charts/eduide-cluster/templates/crds/workspace.yaml similarity index 97% rename from charts/theia-cloud-crds/templates/workspace-spec-resource.yaml rename to charts/eduide-cluster/templates/crds/workspace.yaml index 3c9d5da..6f72ae8 100644 --- a/charts/theia-cloud-crds/templates/workspace-spec-resource.yaml +++ b/charts/eduide-cluster/templates/crds/workspace.yaml @@ -3,6 +3,9 @@ kind: CustomResourceDefinition metadata: name: workspaces.theia.cloud annotations: + # Never delete on uninstall: that would take every live Session, + # Workspace and AppDefinition on the cluster with it. + helm.sh/resource-policy: keep cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/conversion-webhook-certificate spec: group: theia.cloud diff --git a/charts/theia-cloud-base/templates/clusterissuer-for-ca.yaml b/charts/eduide-cluster/templates/issuers/clusterissuer-for-ca.yaml similarity index 100% rename from charts/theia-cloud-base/templates/clusterissuer-for-ca.yaml rename to charts/eduide-cluster/templates/issuers/clusterissuer-for-ca.yaml diff --git a/charts/theia-cloud-base/templates/clusterissuer-production.yaml b/charts/eduide-cluster/templates/issuers/clusterissuer-production.yaml similarity index 100% rename from charts/theia-cloud-base/templates/clusterissuer-production.yaml rename to charts/eduide-cluster/templates/issuers/clusterissuer-production.yaml diff --git a/charts/theia-cloud-base/templates/clusterissuer-selfsigned.yaml b/charts/eduide-cluster/templates/issuers/clusterissuer-selfsigned.yaml similarity index 100% rename from charts/theia-cloud-base/templates/clusterissuer-selfsigned.yaml rename to charts/eduide-cluster/templates/issuers/clusterissuer-selfsigned.yaml diff --git a/charts/theia-cloud-base/templates/theia-cloud-ca-certificate.yaml b/charts/eduide-cluster/templates/issuers/theia-cloud-ca-certificate.yaml similarity index 100% rename from charts/theia-cloud-base/templates/theia-cloud-ca-certificate.yaml rename to charts/eduide-cluster/templates/issuers/theia-cloud-ca-certificate.yaml diff --git a/charts/theia-cloud-base/templates/operator-role.yaml b/charts/eduide-cluster/templates/rbac/clusterrole-operator.yaml similarity index 100% rename from charts/theia-cloud-base/templates/operator-role.yaml rename to charts/eduide-cluster/templates/rbac/clusterrole-operator.yaml diff --git a/charts/theia-cloud-base/templates/service-role.yaml b/charts/eduide-cluster/templates/rbac/clusterrole-service.yaml similarity index 100% rename from charts/theia-cloud-base/templates/service-role.yaml rename to charts/eduide-cluster/templates/rbac/clusterrole-service.yaml diff --git a/charts/eduide-cluster/templates/version-configmap.yaml b/charts/eduide-cluster/templates/version-configmap.yaml new file mode 100644 index 0000000..7a9af3f --- /dev/null +++ b/charts/eduide-cluster/templates/version-configmap.yaml @@ -0,0 +1,17 @@ +{{/* + Marker a tenant release can look for to tell whether this cluster has been + bootstrapped, and with which version. Without it the first sign of a missing + bootstrap is the operator crash-looping on absent CRDs. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: eduide-cluster-version + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: eduide-cluster + app.kubernetes.io/part-of: eduide + app.kubernetes.io/managed-by: {{ .Release.Service }} +data: + platformVersion: {{ .Chart.Version | quote }} + appVersion: {{ .Chart.AppVersion | quote }} diff --git a/charts/theia-cloud-base/values.yaml b/charts/eduide-cluster/values.yaml similarity index 53% rename from charts/theia-cloud-base/values.yaml rename to charts/eduide-cluster/values.yaml index 9761a83..70bbfac 100644 --- a/charts/theia-cloud-base/values.yaml +++ b/charts/eduide-cluster/values.yaml @@ -1,3 +1,10 @@ +# Cluster-scoped half of EduIDE. Installed ONCE per cluster. +# +# The tenant chart (eduide) is installed once per environment and owns +# nothing in here. Splitting them is what removes the race where three +# concurrent tenant deploys all reinstalled the same cluster-scoped +# objects into the default namespace. + issuerca: # -- whether to install the CA certificate signer enable: true @@ -31,3 +38,15 @@ servicerole: certmanager: # -- the namespace where the cert-manager is installed namespace: cert-manager + +# --- CRDs and the conversion webhook --- +conversion: + # -- The image of the webhook container + image: theiacloud/theia-cloud-conversion-webhook:1.2.0-next + # -- The location of where the certificates are mounted into the container (needs to match with application.properties) + certMountPath: /etc/webhook/certs + # -- The certificate reload period in seconds + certReloadPeriod: 604800 + +# -- The cluster issuer to use for the certificate +clusterIssuer: theia-cloud-selfsigned-issuer diff --git a/charts/theia-cloud-crds/.helmignore b/charts/eduide/.helmignore similarity index 100% rename from charts/theia-cloud-crds/.helmignore rename to charts/eduide/.helmignore diff --git a/charts/theia-cloud/.project b/charts/eduide/.project similarity index 100% rename from charts/theia-cloud/.project rename to charts/eduide/.project diff --git a/charts/theia-cloud-base/Chart.yaml b/charts/eduide/Chart.yaml similarity index 85% rename from charts/theia-cloud-base/Chart.yaml rename to charts/eduide/Chart.yaml index d0a7410..9aa18eb 100644 --- a/charts/theia-cloud-base/Chart.yaml +++ b/charts/eduide/Chart.yaml @@ -1,7 +1,8 @@ apiVersion: v2 -name: theia-cloud-base -description: Theia-cloud base chart - +name: eduide +description: | + EduIDE tenant release: operator, REST service, landing page and routes for one + environment. Requires eduide-cluster to be installed on the cluster first. # A chart can be either an 'application' or a 'library' chart. # # Application charts are a collection of templates that can be packaged into versioned archives @@ -11,12 +12,10 @@ description: Theia-cloud base chart # a dependency of application charts to inject those utilities and functions into the rendering # pipeline. Library charts do not define any templates and therefore cannot be deployed. type: application - # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) version: 1.0.0-rc0 - # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. diff --git a/charts/theia-cloud/README.md b/charts/eduide/README.md similarity index 98% rename from charts/theia-cloud/README.md rename to charts/eduide/README.md index 580f598..912d910 100644 --- a/charts/theia-cloud/README.md +++ b/charts/eduide/README.md @@ -1,8 +1,9 @@ -# theia-cloud +# eduide ![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.4.0-next](https://img.shields.io/badge/AppVersion-1.4.0--next-informational?style=flat-square) -A Helm chart for Theia Cloud +EduIDE tenant release: operator, REST service, landing page and routes for one +environment. Requires eduide-cluster to be installed on the cluster first. *This chart was tested with Helm version v3.17.0.* *Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* @@ -128,6 +129,7 @@ A Helm chart for Theia Cloud | service.sentry | object | (see details below) | Values related to Sentry on the service. | | service.sentry.enable | bool | `true` | Whether to set SENTRY_ENABLE=true in the service deployment. | | servicerole.name | string | `"service-api-access"` | | +| skipPreflight | bool | `false` | Skip the check that eduide-cluster is installed on this cluster. Only useful for rendering against a cluster that intentionally lacks it. | ---------------------------------------------- Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/theia-cloud-crds/README.md.gotmpl b/charts/eduide/README.md.gotmpl similarity index 100% rename from charts/theia-cloud-crds/README.md.gotmpl rename to charts/eduide/README.md.gotmpl diff --git a/charts/theia-cloud/logos/cdtcloud.svg b/charts/eduide/logos/cdtcloud.svg similarity index 100% rename from charts/theia-cloud/logos/cdtcloud.svg rename to charts/eduide/logos/cdtcloud.svg diff --git a/charts/theia-cloud/logos/theiablueprint.svg b/charts/eduide/logos/theiablueprint.svg similarity index 100% rename from charts/theia-cloud/logos/theiablueprint.svg rename to charts/eduide/logos/theiablueprint.svg diff --git a/charts/theia-cloud/templates/_gateway-helpers.tpl b/charts/eduide/templates/_gateway-helpers.tpl similarity index 100% rename from charts/theia-cloud/templates/_gateway-helpers.tpl rename to charts/eduide/templates/_gateway-helpers.tpl diff --git a/charts/theia-cloud/templates/_helpers.tpl b/charts/eduide/templates/_helpers.tpl similarity index 100% rename from charts/theia-cloud/templates/_helpers.tpl rename to charts/eduide/templates/_helpers.tpl diff --git a/charts/eduide/templates/_preflight.tpl b/charts/eduide/templates/_preflight.tpl new file mode 100644 index 0000000..8192494 --- /dev/null +++ b/charts/eduide/templates/_preflight.tpl @@ -0,0 +1,22 @@ +{{/* + Fail early and legibly when the cluster has not been bootstrapped. + + Without this the first symptom of a missing eduide-cluster release is the + operator crash-looping because the AppDefinition CRD does not exist, which is + a considerably worse way to find out. + + lookup returns nothing under `helm template`, so this only fires against a + real cluster; rendering and diffing still work offline. +*/}} +{{- define "eduide.preflight" -}} +{{- if not .Values.skipPreflight }} +{{- if .Capabilities.APIVersions.Has "theia.cloud/v1beta11" }} +{{- /* CRDs are present, so the cluster chart is installed. */ -}} +{{- else if .Capabilities.APIVersions.Has "v1" }} +{{- $cm := lookup "v1" "ConfigMap" "eduide-system" "eduide-cluster-version" -}} +{{- if and (not $cm) (not (empty .Release.IsInstall)) }} +{{- fail "eduide-cluster is not installed on this cluster. Run the Bootstrap cluster workflow first, or set skipPreflight=true if you know better." }} +{{- end }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/charts/theia-cloud/templates/gateway.yaml b/charts/eduide/templates/gateway.yaml similarity index 100% rename from charts/theia-cloud/templates/gateway.yaml rename to charts/eduide/templates/gateway.yaml diff --git a/charts/theia-cloud/templates/httproute-instances.yaml b/charts/eduide/templates/httproute-instances.yaml similarity index 100% rename from charts/theia-cloud/templates/httproute-instances.yaml rename to charts/eduide/templates/httproute-instances.yaml diff --git a/charts/theia-cloud/templates/httproute-landing.yaml b/charts/eduide/templates/httproute-landing.yaml similarity index 100% rename from charts/theia-cloud/templates/httproute-landing.yaml rename to charts/eduide/templates/httproute-landing.yaml diff --git a/charts/theia-cloud/templates/httproute-service.yaml b/charts/eduide/templates/httproute-service.yaml similarity index 100% rename from charts/theia-cloud/templates/httproute-service.yaml rename to charts/eduide/templates/httproute-service.yaml diff --git a/charts/theia-cloud/templates/image-preloading.yaml b/charts/eduide/templates/image-preloading.yaml similarity index 100% rename from charts/theia-cloud/templates/image-preloading.yaml rename to charts/eduide/templates/image-preloading.yaml diff --git a/charts/theia-cloud/templates/landing-page-config-map.yaml b/charts/eduide/templates/landing-page-config-map.yaml similarity index 100% rename from charts/theia-cloud/templates/landing-page-config-map.yaml rename to charts/eduide/templates/landing-page-config-map.yaml diff --git a/charts/theia-cloud/templates/landing-page.yaml b/charts/eduide/templates/landing-page.yaml similarity index 100% rename from charts/theia-cloud/templates/landing-page.yaml rename to charts/eduide/templates/landing-page.yaml diff --git a/charts/theia-cloud/templates/oauth2-configmap-htmlpage.yaml b/charts/eduide/templates/oauth2-configmap-htmlpage.yaml similarity index 100% rename from charts/theia-cloud/templates/oauth2-configmap-htmlpage.yaml rename to charts/eduide/templates/oauth2-configmap-htmlpage.yaml diff --git a/charts/theia-cloud/templates/oauth2-configmap-oauth2proxy-keycloak.yaml b/charts/eduide/templates/oauth2-configmap-oauth2proxy-keycloak.yaml similarity index 100% rename from charts/theia-cloud/templates/oauth2-configmap-oauth2proxy-keycloak.yaml rename to charts/eduide/templates/oauth2-configmap-oauth2proxy-keycloak.yaml diff --git a/charts/theia-cloud/templates/operator-api-service-account.yaml b/charts/eduide/templates/operator-api-service-account.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-api-service-account.yaml rename to charts/eduide/templates/operator-api-service-account.yaml diff --git a/charts/theia-cloud/templates/operator-configmap-logging.yaml b/charts/eduide/templates/operator-configmap-logging.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-configmap-logging.yaml rename to charts/eduide/templates/operator-configmap-logging.yaml diff --git a/charts/theia-cloud/templates/operator-configmap.yaml b/charts/eduide/templates/operator-configmap.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-configmap.yaml rename to charts/eduide/templates/operator-configmap.yaml diff --git a/charts/theia-cloud/templates/operator-gateway-role.yaml b/charts/eduide/templates/operator-gateway-role.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-gateway-role.yaml rename to charts/eduide/templates/operator-gateway-role.yaml diff --git a/charts/theia-cloud/templates/operator-role.yaml b/charts/eduide/templates/operator-role.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-role.yaml rename to charts/eduide/templates/operator-role.yaml diff --git a/charts/theia-cloud/templates/operator.yaml b/charts/eduide/templates/operator.yaml similarity index 100% rename from charts/theia-cloud/templates/operator.yaml rename to charts/eduide/templates/operator.yaml diff --git a/charts/theia-cloud/templates/service-api-service-account.yaml b/charts/eduide/templates/service-api-service-account.yaml similarity index 100% rename from charts/theia-cloud/templates/service-api-service-account.yaml rename to charts/eduide/templates/service-api-service-account.yaml diff --git a/charts/theia-cloud/templates/service-configmap.yaml b/charts/eduide/templates/service-configmap.yaml similarity index 100% rename from charts/theia-cloud/templates/service-configmap.yaml rename to charts/eduide/templates/service-configmap.yaml diff --git a/charts/theia-cloud/templates/service-role.yaml b/charts/eduide/templates/service-role.yaml similarity index 100% rename from charts/theia-cloud/templates/service-role.yaml rename to charts/eduide/templates/service-role.yaml diff --git a/charts/theia-cloud/templates/service.yaml b/charts/eduide/templates/service.yaml similarity index 100% rename from charts/theia-cloud/templates/service.yaml rename to charts/eduide/templates/service.yaml diff --git a/charts/theia-cloud/templates/theia-appdefinition-spec.yaml b/charts/eduide/templates/theia-appdefinition-spec.yaml similarity index 100% rename from charts/theia-cloud/templates/theia-appdefinition-spec.yaml rename to charts/eduide/templates/theia-appdefinition-spec.yaml diff --git a/charts/theia-cloud/values.yaml b/charts/eduide/values.yaml similarity index 99% rename from charts/theia-cloud/values.yaml rename to charts/eduide/values.yaml index 392c9ac..5a13344 100644 --- a/charts/theia-cloud/values.yaml +++ b/charts/eduide/values.yaml @@ -434,3 +434,7 @@ preloading: # -- Optional: Override the imagePullPolicy for the image preloading containers. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: + +# -- Skip the check that eduide-cluster is installed on this cluster. +# Only useful for rendering against a cluster that intentionally lacks it. +skipPreflight: false diff --git a/charts/theia-cloud-crds/Chart.yaml b/charts/theia-cloud-crds/Chart.yaml deleted file mode 100644 index d1df032..0000000 --- a/charts/theia-cloud-crds/Chart.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: v2 -name: theia-cloud-crds -description: A Helm chart for the custom resource definitions (CRDs) of Theia Cloud - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.0-rc0 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: "1.2.0-next" diff --git a/charts/theia-cloud-crds/README.md b/charts/theia-cloud-crds/README.md deleted file mode 100644 index 114976a..0000000 --- a/charts/theia-cloud-crds/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# theia-cloud-crds - -![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0-next](https://img.shields.io/badge/AppVersion-1.2.0--next-informational?style=flat-square) - -A Helm chart for the custom resource definitions (CRDs) of Theia Cloud - -*This chart was tested with Helm version v3.17.0.* -*Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* - -## Values - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| clusterIssuer | string | `"theia-cloud-selfsigned-issuer"` | The cluster issuer to use for the certificate | -| conversion.certMountPath | string | `"/etc/webhook/certs"` | The location of where the certificates are mounted into the container (needs to match with application.properties) | -| conversion.certReloadPeriod | int | `604800` | The certificate reload period in seconds | -| conversion.image | string | `"theiacloud/theia-cloud-conversion-webhook:1.2.0-next"` | The image of the webhook container | - ----------------------------------------------- -Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/theia-cloud-crds/values.yaml b/charts/theia-cloud-crds/values.yaml deleted file mode 100644 index 0829c42..0000000 --- a/charts/theia-cloud-crds/values.yaml +++ /dev/null @@ -1,10 +0,0 @@ -conversion: - # -- The image of the webhook container - image: theiacloud/theia-cloud-conversion-webhook:1.2.0-next - # -- The location of where the certificates are mounted into the container (needs to match with application.properties) - certMountPath: /etc/webhook/certs - # -- The certificate reload period in seconds - certReloadPeriod: 604800 - -# -- The cluster issuer to use for the certificate -clusterIssuer: theia-cloud-selfsigned-issuer diff --git a/charts/theia-cloud/.helmignore b/charts/theia-cloud/.helmignore deleted file mode 100644 index 0e8a0eb..0000000 --- a/charts/theia-cloud/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/charts/theia-cloud/Chart.yaml b/charts/theia-cloud/Chart.yaml deleted file mode 100644 index 84f47bc..0000000 --- a/charts/theia-cloud/Chart.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: v2 -name: theia-cloud -description: A Helm chart for Theia Cloud -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.0-rc0 -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: "1.4.0-next" diff --git a/charts/theia-cloud/README.md.gotmpl b/charts/theia-cloud/README.md.gotmpl deleted file mode 100644 index f7d38c6..0000000 --- a/charts/theia-cloud/README.md.gotmpl +++ /dev/null @@ -1,21 +0,0 @@ -{{ template "chart.header" . }} -{{ template "chart.deprecationWarning" . }} - -{{ template "chart.badgesSection" . }} - -{{ template "chart.description" . }} - -*This chart was tested with Helm version v3.17.0.* -*Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* - -{{ template "chart.homepageLine" . }} - -{{ template "chart.maintainersSection" . }} - -{{ template "chart.sourcesSection" . }} - -{{ template "chart.requirementsSection" . }} - -{{ template "chart.valuesSection" . }} - -{{ template "helm-docs.versionFooter" . }} diff --git a/docs/charts.md b/docs/charts.md new file mode 100644 index 0000000..90bcc8f --- /dev/null +++ b/docs/charts.md @@ -0,0 +1,90 @@ +# The charts + +Two charts, released together with the same version. + +| Chart | Installed | Owns | +|---|---|---| +| `eduide-cluster` | once per **cluster** | CRDs, the conversion webhook, ClusterRoles, cert-manager issuers | +| `eduide` | once per **environment** | operator, REST service, landing page, routes, config | + +```bash +# once per cluster +helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster \ + --version 1.0.0-rc0 -n eduide-system --create-namespace + +# once per environment +helm install eduide oci://ghcr.io/eduide/charts/eduide \ + --version 1.0.0-rc0 -n test1 -f my-values.yaml +``` + +## Why two charts and not one + +Everything in `eduide-cluster` is cluster-scoped or singular: a CRD exists once, +and a CRD names exactly one conversion webhook service. Everything in `eduide` +exists once per environment. + +Before the split, every tenant deploy also reinstalled the cluster-scoped +charts into the `default` namespace. Three concurrent test deploys therefore +raced over the same objects, which was worked around with a six-attempt retry +loop. One owner removes the race instead of retrying through it. + +It also means a tenant upgrade cannot touch a CRD, so it cannot break the other +environments on the same cluster. + +### The conversion webhook belongs to the cluster + +A CRD's `conversion.webhook.clientConfig.service` names one namespace and one +service. If the webhook were a tenant resource, "which of the four environments +on this cluster serves CRD conversion?" would have no answer, and tenants on +different chart versions would fight over one conversion schema. + +## Install order + +`eduide-cluster` first. The tenant chart checks for it and fails with a usable +message if it is missing; without that check the first symptom is the operator +crash-looping on an absent CRD. Set `skipPreflight=true` to bypass it. + +## CRDs are annotated `helm.sh/resource-policy: keep` + +`helm uninstall eduide-cluster` will not delete them. Deleting a CRD deletes +every object of that kind, which here means every live Session, Workspace and +AppDefinition on the cluster. Remove them by hand if you really mean to. + +They are ordinary templates rather than files under `crds/`, because Helm never +upgrades anything in `crds/` and these change with almost every release +(`v1beta8` through `v1beta11` so far). + +## Renaming an existing installation + +Helm will not manage an object it did not create, so pointing a new release +name at existing objects normally deletes and recreates everything. Adopt them +instead: + +```bash +DRY_RUN=1 ./scripts/adopt-release.sh test1 eduide \ + deploy/operator-deployment deploy/service-deployment deploy/landing-page-deployment +``` + +Drop `DRY_RUN` once the output looks right, then upgrade under the new name. + +## Resource names are deliberately not release-prefixed + +The operator mounts `oauth2-proxy-config`, `oauth2-templates` and +`oauth2-emails` **by literal name** into every session pod +(`AddedHandlerUtil.java:88` and `templateDeployment.yaml`). Prefixing them would +break every running session. + +One install means one namespace, so prefixing buys no collision protection +anyway. Standard `app.kubernetes.io/*` labels give the same grouping without +the rename, and labels are additive on upgrade. + +## Checking a change + +```bash +helm lint charts/eduide charts/eduide-cluster +./scripts/render-envs.sh /tmp/out # render every real environment +``` + +CI additionally renders the PR base and head and posts the diff, which is the +only reliable answer to "what will this do to production". For a pure refactor +the expected result is an empty diff. diff --git a/scripts/adopt-release.sh b/scripts/adopt-release.sh new file mode 100755 index 0000000..4e2c164 --- /dev/null +++ b/scripts/adopt-release.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Hand existing objects over to a new Helm release without recreating them. +# +# ./scripts/adopt-release.sh ... +# DRY_RUN=1 ./scripts/adopt-release.sh ... # print, change nothing +# +# Helm refuses to manage an object it did not create, and renaming a release +# would otherwise mean deleting and recreating every object in it. Annotating +# them first makes the rename an in-place upgrade instead. +# +# Generalises the inline `kubectl annotate role/operator-sidecar-pod-restart` +# hack that had grown into the old deploy workflow. +# +# Idempotent. Run it immediately before the first upgrade under the new name. + +set -euo pipefail + +NS="${1:?usage: adopt-release.sh ...}" +REL="${2:?usage: adopt-release.sh ...}" +shift 2 +[[ $# -gt 0 ]] || { echo "no objects given" >&2; exit 2; } + +run() { + if [[ -n "${DRY_RUN:-}" ]]; then echo " would: $*"; else "$@"; fi +} + +for obj in "$@"; do + if ! kubectl -n "$NS" get "$obj" >/dev/null 2>&1; then + echo " skip $obj (does not exist)" + continue + fi + owner=$(kubectl -n "$NS" get "$obj" -o jsonpath={.metadata.annotations.meta.helm.sh/release-name} 2>/dev/null || true) + if [[ "$owner" == "$REL" ]]; then + echo " ok $obj (already owned by $REL)" + continue + fi + echo " adopt $obj ${owner:+(was $owner)}" + run kubectl -n "$NS" annotate --overwrite "$obj" \ + "meta.helm.sh/release-name=$REL" "meta.helm.sh/release-namespace=$NS" + run kubectl -n "$NS" label --overwrite "$obj" "app.kubernetes.io/managed-by=Helm" +done diff --git a/scripts/render-envs.sh b/scripts/render-envs.sh index c33427a..c028782 100755 --- a/scripts/render-envs.sh +++ b/scripts/render-envs.sh @@ -42,8 +42,13 @@ if [[ -n "$CHARTS_ARG" ]]; then else CHARTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/charts" fi -[[ -d "$CHARTS_DIR/theia-cloud" ]] || { - echo "No theia-cloud chart under $CHARTS_DIR" >&2 +# The tenant chart was called theia-cloud before the split into +# eduide (tenant) and eduide-cluster (cluster-scoped). Accept both so this +# script can render a base checkout that predates the rename. +TENANT_CHART=eduide +[[ -d "$CHARTS_DIR/$TENANT_CHART" ]] || TENANT_CHART=theia-cloud +[[ -d "$CHARTS_DIR/$TENANT_CHART" ]] || { + echo "No eduide or theia-cloud chart under $CHARTS_DIR" >&2 exit 2 } echo "rendering from $CHARTS_DIR" @@ -76,7 +81,7 @@ for dir in "$DEPLOY"/deployments/*/; do yq -r 'explode(.) | ."theia-cloud"' "$dir/values.yaml" > "$values" ns="$(yq -r '.hosts.configuration.landing // "default"' "$values")" - if ! helm template theia-cloud "$CHARTS_DIR/theia-cloud" \ + if ! helm template theia-cloud "$CHARTS_DIR/$TENANT_CHART" \ -f "$values" --namespace "$ns" 2> "$OUT/$env_name.err" | mask > "$OUT/$env_name.yaml"; then echo "RENDER FAILED for $env_name:" >&2 cat "$OUT/$env_name.err" >&2 @@ -88,7 +93,7 @@ for dir in "$DEPLOY"/deployments/*/; do done # The cluster-scoped charts take no per-environment values. -for chart in theia-cloud-base theia-cloud-crds; do +for chart in eduide-cluster; do helm template "$chart" "$CHARTS_DIR/$chart" --namespace default | mask > "$OUT/_$chart.yaml" printf ' rendered %-45s %s resources\n' "$chart" "$(grep -c '^kind:' "$OUT/_$chart.yaml" || true)" rendered=$((rendered + 1)) From a3f86d87572dbf850502292c4ba4ca1942b2d6a9 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 18:58:15 +0200 Subject: [PATCH 02/14] feat: release train Cuts one platform version across the four repositories. The order is build, verify, then tag. Tagging first is the obvious design and the wrong one: building 15 multi-GB IDE images is the flakiest step in the pipeline, and a failure after tagging strands immutable vX.Y.Z tags on repositories whose images were never published. Building first makes a flake cost a re-run. Every expected image is checked to exist and to be multi-arch before any chart claims to pin it, which removes the failure where a chart pins a tag that was never pushed and nobody finds out until a deploy. dry_run defaults to true and reports what would be built. swift is deliberately named as excluded rather than left implicit: it exists under images/ and in the compose file, the README advertises it, and it is in no build matrix, so listing it would fail every release and omitting it silently would let the gap rot. Adds docs/releasing.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/release-train.yml | 230 ++++++++++++++++++++++++++++ docs/releasing.md | 64 ++++++++ 2 files changed, 294 insertions(+) create mode 100644 .github/workflows/release-train.yml create mode 100644 docs/releasing.md diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml new file mode 100644 index 0000000..ff1fa6e --- /dev/null +++ b/.github/workflows/release-train.yml @@ -0,0 +1,230 @@ +# Cut one EduIDE platform release across four repositories. +# +# The order is deliberate: BUILD, VERIFY, then TAG. +# +# Tagging first is the obvious design and the wrong one. Building 15 multi-GB +# IDE images is the flakiest step in the pipeline; if image 17 of 19 fails after +# the tags exist, immutable vX.Y.Z tags are stranded on repositories whose +# images were never published. Building first means a flake costs a re-run. +# +# Every image is checked to actually exist before any chart claims to pin it, +# which removes the "chart pins a tag that was never pushed" failure mode. + +name: Release train + +on: + workflow_dispatch: + inputs: + version: + description: "Platform version, e.g. 2.3.0 or 2.3.0-rc.1 (no leading v)" + required: true + type: string + dry_run: + description: "Plan only. Build nothing, tag nothing, publish nothing." + type: boolean + default: true + +permissions: + contents: write + packages: write + +env: + # Everything the platform version pins. swift is deliberately absent: it + # exists under images/ and in the compose file but is in no build matrix, so + # it is never published. Listing it here would fail every release; leaving it + # implicit would let the gap rot unnoticed. + CLOUD_IMAGES: "eduide-cloud/operator eduide-cloud/service eduide-cloud/conversion-webhook" + IDE_IMAGES: "eduide/base eduide/c eduide/c-templates eduide/haskell eduide/java-17 eduide/java-17-templates eduide/javascript eduide/ocaml eduide/python eduide/rust" + OTHER_IMAGES: "eduidec-landing-page" + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@v4 + - id: v + env: + V: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "$V" == v* ]]; then + echo "::error::give the version without a leading v (chart versions and image tags are X.Y.Z)" + exit 1 + fi + if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::'$V' is not semver" + exit 1 + fi + if git ls-remote --tags origin "refs/tags/v$V" | grep -q .; then + echo "::error::tag v$V already exists in this repository" + exit 1 + fi + echo "version=$V" >> "$GITHUB_OUTPUT" + + # Build every component at the release tag, without tagging anything yet. + build: + needs: validate + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + repo: [EduIDE-Cloud, EduIDE, EduIDE-Landing-Page] + steps: + - name: Dispatch the component build + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + wf=$([[ "${{ matrix.repo }}" == "EduIDE-Landing-Page" ]] && echo docker-build.yml || echo build.yml) + gh workflow run "$wf" --repo "EduIDE/${{ matrix.repo }}" --ref main -f image_tag="$V" + echo "dispatched $wf on ${{ matrix.repo }} with image_tag=$V" + + - name: Wait for it + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + sleep 20 + for _ in $(seq 1 120); do + read -r run_id status conclusion < <( + gh run list --repo "EduIDE/${{ matrix.repo }}" --limit 1 \ + --json databaseId,status,conclusion \ + --jq '.[0] | "\(.databaseId) \(.status) \(.conclusion)"') + if [[ "$status" == "completed" ]]; then + if [[ "$conclusion" != "success" ]]; then + echo "::error::${{ matrix.repo }} build ${run_id} concluded ${conclusion}" + exit 1 + fi + echo "${{ matrix.repo }} build ${run_id} succeeded" + exit 0 + fi + sleep 30 + done + echo "::error::${{ matrix.repo }} build did not finish in an hour"; exit 1 + + # Nothing may claim to pin a tag that was never pushed. + verify-images: + needs: [validate, build] + if: ${{ always() && needs.validate.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Install crane + run: | + set -euo pipefail + curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \ + | sudo tar -xz -C /usr/local/bin crane + echo '${{ secrets.GITHUB_TOKEN }}' | crane auth login ghcr.io -u '${{ github.actor }}' --password-stdin + + - name: Every expected image exists, for both architectures + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -uo pipefail + missing=0; single=0; n=0 + for img in $CLOUD_IMAGES $IDE_IMAGES $OTHER_IMAGES; do + n=$((n + 1)) + ref="ghcr.io/eduide/${img}:${V}" + if ! manifest=$(crane manifest "$ref" 2>/dev/null); then + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + echo " would need $ref" + else + echo "::error::missing $ref"; missing=$((missing + 1)) + fi + continue + fi + arches=$(jq -r '[.manifests[]?.platform.architecture] | sort | join(",")' <<<"$manifest") + if [[ "$arches" != *amd64* || "$arches" != *arm64* ]]; then + echo "::error::$ref is not multi-arch (has: ${arches:-none})"; single=$((single + 1)) + else + echo " ok $ref [$arches]" + fi + done + echo "checked $n images" + if [[ "${{ inputs.dry_run }}" == "true" ]]; then exit 0; fi + [[ $missing -eq 0 && $single -eq 0 ]] || exit 1 + + # Only now do immutable tags appear. + tag-components: + needs: [validate, verify-images] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + strategy: + matrix: + repo: [EduIDE-Cloud, EduIDE, EduIDE-Landing-Page] + steps: + - name: Tag and release + env: + GH_TOKEN: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + pre="" + [[ "$V" == *-* ]] && pre="--prerelease" + gh release create "v$V" --repo "EduIDE/${{ matrix.repo }}" \ + --title "v$V" --generate-notes $pre \ + --notes "Images for this release were built and verified before the tag was created." + + publish-charts: + needs: [validate, verify-images] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} + - uses: azure/setup-helm@v4 + with: + version: v3.16.3 + - name: Bump both charts and tag + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + for c in eduide eduide-cluster; do + sed -i -E "s/^version: .*/version: ${V}/" "charts/$c/Chart.yaml" + sed -i -E "s/^appVersion: .*/appVersion: \"${V}\"/" "charts/$c/Chart.yaml" + done + git config user.name "eduide-release-train" + git config user.email "noreply@github.com" + git commit -qam "release: ${V}" + git tag -a "v${V}" -m "EduIDE ${V}" + git push origin HEAD:main "v${V}" + - name: Publish + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + echo '${{ secrets.GITHUB_TOKEN }}' | helm registry login ghcr.io -u '${{ github.actor }}' --password-stdin + mkdir -p dist + for c in eduide-cluster eduide; do + helm package "charts/$c" --destination dist + helm push "dist/${c}-${V}.tgz" oci://ghcr.io/eduide/charts + done + + summary: + needs: [validate, verify-images, tag-components, publish-charts] + if: always() + runs-on: ubuntu-latest + steps: + - env: + V: ${{ needs.validate.outputs.version }} + run: | + { + echo "## EduIDE ${V}" + echo "" + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + echo "Dry run. Nothing was built, tagged or published." + else + echo '```bash' + echo "helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster --version ${V} -n eduide-system --create-namespace" + echo "helm install eduide oci://ghcr.io/eduide/charts/eduide --version ${V} -n " + echo '```' + echo "" + echo "Bump \`spec.platform.chartVersion\` in the relevant \`environments/*/env.yaml\` to roll this out. Production is never deployed automatically." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..f91624b --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,64 @@ +# Releasing + +One platform version spans four repositories: EduIDE-Cloud, EduIDE, +EduIDE-Landing-Page and the charts here. The `Release train` workflow cuts all +of it. + +``` +Actions -> Release train -> Run workflow + version: 2.3.0 (no leading v) + dry_run: true leave this on the first time +``` + +## What it does, and why in that order + +1. **Validate** the version is semver and the tag is free. +2. **Build** every component at that tag, by dispatching each repository's own + build workflow with `image_tag`. **Nothing is tagged yet.** +3. **Verify** all 14 images exist in GHCR and are multi-arch. +4. **Tag** the three component repositories. +5. **Publish** both charts at that version. + +Tagging first is the obvious design and the wrong one. Building 15 multi-GB IDE +images is the flakiest step in the pipeline; if one fails after the tags exist, +immutable `vX.Y.Z` tags are stranded on repositories whose images were never +published. Building first makes a flake cost a re-run. + +Step 3 exists because a chart pinning a tag that was never pushed is a failure +that only shows up at deploy time, in whichever environment picks it up first. + +## Versions + +- Git tags are `vX.Y.Z`. +- Image tags and chart versions are `X.Y.Z`, so a chart's `appVersion` and the + image tag it refers to are the same string. +- Release candidates are `2.3.0-rc.1` throughout, published to the same place + and installed with an explicit `--version`. + +The four repositories move together. A one-line landing page fix therefore +rebuilds everything, which is the price of a version number that means +something. If a component's cadence ever diverges enough for that to hurt, the +alternative is a bill of materials pinning each component in the chart values, +with `appVersion` demoted to a label. + +## Rolling a release out + +The train does **not** deploy. It opens nothing and changes no environment. + +```yaml +# environments/prod-tum/env.yaml +spec: + platform: + chartVersion: 2.3.0 # the only line that changes +``` + +Merge that, then run `Deploy environment`. Production is never deployed +automatically. + +## swift + +`swift` is deliberately absent from the image list. It exists under `images/` +and in `docker-compose.images.yml`, and the README advertises it, but it is in +no build matrix and has never been published. Listing it would fail every +release; leaving it implicit would let the gap rot unnoticed, so it is named +in the workflow as an explicit exclusion instead. From 907217c86b4f7ef272de83f7f64c185f3bedaade Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 19:04:53 +0200 Subject: [PATCH 03/14] docs: AGENTS.md, agent skills, and a check that stops them rotting Both pre-existing AGENTS.md files in this org had decayed into fiction. One named a CI job that no longer exists and a package.json path that never existed; the other described a landing page deleted months earlier. Nothing checked them, so nothing noticed. Adds AGENTS.md here, with CLAUDE.md symlinked to it so Claude Code, Codex, Cursor and Copilot all read the same file rather than three drifting copies. The content is the things that actually catch people out, not a tour of the directory tree: why resource names must not be release-prefixed, why the Gateway listener prefix is not the landing host, why a blanket image tag breaks a namespace, why preloading cannot sit under --wait, and which lookup calls make rendering nondeterministic. scripts/check-agents-md.sh fails when AGENTS.md references a repo path that does not exist, and runs in CI. Verified it fails on a bad path rather than just passing on a good one. Also adds .claude/skills/ for the recurring jobs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .claude/skills/chart-change.md | 50 ++++++++++++++++++++ .github/workflows/ci.yml | 3 ++ "2,\n echo" | 0 AGENTS.md | 85 ++++++++++++++++++++++++++++++++++ scripts/check-agents-md.sh | 35 ++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 .claude/skills/chart-change.md create mode 100644 "2,\n echo" create mode 100644 AGENTS.md create mode 100755 scripts/check-agents-md.sh diff --git a/.claude/skills/chart-change.md b/.claude/skills/chart-change.md new file mode 100644 index 0000000..96239f3 --- /dev/null +++ b/.claude/skills/chart-change.md @@ -0,0 +1,50 @@ +--- +name: chart-change +description: Change an EduIDE Helm chart safely. Use when editing templates or values in EduIDE-Helm, or when asked why a chart change is or is not behaviour-preserving. +--- + +# Changing a chart + +The question that matters is not "does it lint" but **"what does this do to the +five live environments"**. There is a command for that. + +## Loop + +```bash +# 1. baseline BEFORE touching anything +./scripts/render-envs.sh /tmp/before + +# 2. make one change, one concern at a time + +# 3. what did it actually do? +./scripts/render-envs.sh /tmp/after +diff -r /tmp/before /tmp/after +``` + +For a refactor the diff must be **empty**. If it is not, either the refactor is +not behaviour-preserving or the change was larger than intended — both worth +knowing before review. + +For an intentional change, the diff should contain exactly that change and +nothing else. Ten lines across five environments is one line per environment. + +## Then + +```bash +helm lint charts/eduide charts/eduide-cluster +# bump the chart version - CI enforces it, and release.yml silently +# publishes nothing if you forget +docker run --rm -v "$PWD/charts:/helm-docs" -u "$(id -u)" jnorwood/helm-docs:v1.14.2 +``` + +## Traps + +- **Never release-prefix resource names.** The operator mounts + `oauth2-proxy-config`, `oauth2-templates` and `oauth2-emails` by literal name + into every session pod. +- **Adding a `lookup`?** Add it to the mask list in `scripts/render-envs.sh` too, + or every future PR shows a false diff. +- **A Go-template comment is `{{/* */}}`.** A YAML `#` comment inside a template + ends up in the rendered manifest and shows as a diff. +- **`helm lint` accepts invalid YAML.** Duplicate keys pass lint and render, and + are only caught by `kubeconform`. Run it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b91aaa9..9dc7dae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: with: version: ${{ env.HELM_VERSION }} + - name: AGENTS.md does not reference missing paths + run: ./scripts/check-agents-md.sh + - name: helm lint run: | set -euo pipefail diff --git "a/2,\n echo" "b/2,\n echo" new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1e4b4de --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# AGENTS.md — EduIDE-Helm + +The Helm charts. This is the installable product: an administrator anywhere +gets EduIDE from here. + +`CLAUDE.md` is a symlink to this file. + +## Two charts + +| Chart | Installed | Owns | +|---|---|---| +| `eduide-cluster` | once per **cluster** | CRDs, conversion webhook, ClusterRoles, cert-manager issuers | +| `eduide` | once per **environment** | operator, REST service, landing page, routes | + +Released together, same version. Environment values live in +**EduIDE-deployment**, not here. + +## Before you change a template + +```bash +helm lint charts/eduide charts/eduide-cluster +./scripts/render-envs.sh /tmp/out # renders every real environment +``` + +CI additionally renders the PR base and head and posts the diff. **For a pure +refactor the expected result is an empty diff.** That is the acceptance +criterion, not "it still lints". + +## Things that will catch you out + +**Do not release-prefix resource names.** The operator mounts +`oauth2-proxy-config`, `oauth2-templates` and `oauth2-emails` **by literal name** +into every session pod — see `AddedHandlerUtil.java:88` and +`templateDeployment.yaml` in EduIDE-Cloud. Renaming them breaks every running +session. One install is one namespace, so prefixing buys no collision +protection anyway. Use `app.kubernetes.io/*` labels for grouping; they are +additive on upgrade. + +**CRDs are templates, not `crds/`.** Helm never upgrades anything in `crds/`, +and these change most releases (`v1beta8` through `v1beta11`). They carry +`helm.sh/resource-policy: keep` because deleting a CRD deletes every live +Session, Workspace and AppDefinition on the cluster. + +**The conversion webhook belongs to the cluster chart.** A CRD names exactly +one conversion service, so it cannot be a per-environment resource. + +**Preloading must not be under `--wait`.** It pulls ~10 multi-GB images on every +node. Inside the main release it would time out and `--atomic` would roll back a +healthy deploy. + +**`hosts.usePaths` is gone.** Do not reintroduce path-based routing without +wiring it through the Gateway API properly; it branched in seven files and no +environment ever set it. + +**Host names come from the helpers.** `theia-cloud.host.{base,landing,service,instance}` +and `theia-cloud.url.service` in `_helpers.tpl`. Before those existed the same +two-line `printf` was re-derived inline in about a dozen places, each wrapped in +`tpl (X | toString) .`. + +## `lookup` makes rendering nondeterministic + +`theia-appdefinitions` preserves live scaling values and `theia-shared-cache` +generates a Redis password when its lookup finds nothing. Both are correct, but +`lookup` returns empty under `helm template`, so both are masked in +`scripts/render-envs.sh`. **Add any new `lookup` to the mask list**, or +render-diff fills with false changes and stops being read. + +## Releasing + +`Release train` cuts a version across all four repositories. It builds, then +verifies every image exists and is multi-arch, and only then tags — because a +failure after tagging strands immutable tags on repositories whose images were +never published. See `docs/releasing.md`. + +## Conventions + +- A changed chart must have a bumped version. CI enforces this: `release.yml` + skips a version that already exists, so a forgotten bump publishes nothing + and fails silently. It has happened three times. +- Chart READMEs are generated. Run helm-docs and commit the result; CI fails on + drift. +- `kubeconform` skips `HTTPRoute`: the CRDs-catalog schema declares + `minItems: 1` on `spec.rules`, but the upstream Gateway API CRD does not, and + `httproute-instances.yaml` ships `rules: []` deliberately for the operator to + patch. diff --git a/scripts/check-agents-md.sh b/scripts/check-agents-md.sh new file mode 100755 index 0000000..2aa8353 --- /dev/null +++ b/scripts/check-agents-md.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Flag paths referenced by AGENTS.md that no longer exist. +# +# Both pre-existing AGENTS.md files in this org had rotted into fiction. One +# named a CI job that had been deleted and a package.json path that does not +# exist; the other described a landing page removed months earlier. Nothing +# checked them, so nothing noticed. +# +# Only repo-relative, extension-bearing paths in backticks are checked. Prose is +# not validated, and this is a lint rather than a proof. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOC="$ROOT/AGENTS.md" +[[ -f "$DOC" ]] || { echo "no AGENTS.md here"; exit 0; } + +missing=0 +while read -r p; do + [[ "$p" == */* ]] || continue # must look like a path + [[ "$p" == *.* ]] || continue # and carry an extension + case "$p" in + http*|*ghcr.io*|*github.com*|oci://*|*.tum.de*) continue ;; + esac + if [[ ! -e "$ROOT/$p" ]]; then + echo " missing: $p" + missing=1 + fi +done < <(grep -oE '`[A-Za-z0-9_./-]+`' "$DOC" | tr -d '`' | sort -u) + +if [[ $missing -ne 0 ]]; then + echo "AGENTS.md references paths that do not exist. Fix the doc or the path." + exit 1 +fi +echo "AGENTS.md: every referenced path exists" From 0023324f8c56a967d2bf2dc840942b6aa765f5d5 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 15:09:07 +0200 Subject: [PATCH 04/14] fix: the release train no longer writes to main The publish job bumped Chart.yaml and pushed the commit to main. That makes release automation able to trigger the workflows that watch main, which is a cycle waiting to happen. The bump is now a reviewed pull request, and the workflow CHECKS the charts are already at the requested version, failing with instructions if they are not. The only thing it still pushes is the release tag. Documents the whole procedure in the README rather than a separate file, for a human and for an agent: dry run, bump in a PR, run for real, then roll out by bumping chartVersion in EduIDE-deployment. Includes a checklist and a table of the failure messages and what each one means. Adds .claude/skills/cut-a-release.md so an agent follows the same steps, including the instruction not to bump the version from automation. Removes docs/releasing.md, which said the same thing in a second place. Also removes a zero-byte file with a mangled name, created by a shell redirection accident in the previous commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .claude/skills/cut-a-release.md | 76 ++++++++++++ .github/workflows/release-train.yml | 49 ++++++-- "2,\n echo" | 0 AGENTS.md | 8 +- README.md | 185 +++++++++++++++++++++------- docs/releasing.md | 64 ---------- 6 files changed, 263 insertions(+), 119 deletions(-) create mode 100644 .claude/skills/cut-a-release.md delete mode 100644 "2,\n echo" delete mode 100644 docs/releasing.md diff --git a/.claude/skills/cut-a-release.md b/.claude/skills/cut-a-release.md new file mode 100644 index 0000000..fbddcc9 --- /dev/null +++ b/.claude/skills/cut-a-release.md @@ -0,0 +1,76 @@ +--- +name: cut-a-release +description: Cut an EduIDE platform release across all four repositories. Use when asked to release, cut a version, publish charts, or ship a version of EduIDE. +--- + +# Cutting a release + +A release is one version across EduIDE-Cloud, EduIDE, EduIDE-Landing-Page and +EduIDE-Helm. + +**Do not bump the chart version from a workflow or by pushing to `main`.** The +release train deliberately does not do this, and neither should you. It checks +the charts are already at the requested version and fails otherwise. The bump is +a reviewed pull request; automation that pushes to `main` triggers the workflows +watching `main`. + +## 1. Dry run first, always + +```bash +gh workflow run release-train.yml --repo EduIDE/EduIDE-Helm \ + -f version=2.3.0 -f dry_run=true +``` + +Read the summary. It reports which images the version would need, and builds +nothing. + +## 2. Bump both charts in a pull request + +Both charts, both fields — four values, all identical: + +```yaml +# charts/eduide/Chart.yaml AND charts/eduide-cluster/Chart.yaml +version: 2.3.0 +appVersion: "2.3.0" +``` + +Then regenerate the READMEs, or the `docs-drift` job fails: + +```bash +docker run --rm -v "$PWD/charts:/helm-docs" -u "$(id -u)" jnorwood/helm-docs:v1.14.2 +``` + +Open the PR and let CI run. Do not merge it yourself unless asked to. + +## 3. Run it for real + +```bash +gh workflow run release-train.yml --repo EduIDE/EduIDE-Helm \ + -f version=2.3.0 -f dry_run=false +``` + +Order: validate, build all 14 images, verify they exist and are multi-arch, +**then** tag, then publish. Building before tagging means a flaky image build +costs a re-run rather than stranding immutable tags on repositories whose +images were never published. + +## 4. Roll it out separately + +The train deploys nothing. In EduIDE-deployment, bump +`spec.platform.chartVersion` in the relevant `environments/*/env.yaml`, in a +pull request. Production is never deployed automatically. + +## Version forms + +- git tags `vX.Y.Z` +- chart `version`, chart `appVersion` and image tags all `X.Y.Z` +- release candidates `2.3.0-rc.1` throughout + +## Common failures + +| Message | Meaning | +|---|---| +| `version is 'X', expected 'Y'` | step 2 skipped, or only one chart/field bumped | +| `missing ghcr.io/...` | a component build failed; check that repository's Actions | +| `is not multi-arch` | one architecture failed; re-run the whole build, not just the merge job | +| `tag v2.3.0 already exists` | pick the next version, tags are immutable | diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index ff1fa6e..86a9b87 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -169,37 +169,64 @@ jobs: --title "v$V" --generate-notes $pre \ --notes "Images for this release were built and verified before the tag was created." + # The version bump is a reviewed commit, not something this workflow makes. + # A workflow that pushes to main triggers the workflows that watch main, and + # release automation able to trigger itself is a bad thing to own. This job + # therefore CHECKS the charts are already at the requested version and fails + # with instructions if they are not. publish-charts: needs: [validate, verify-images] if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - token: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} - uses: azure/setup-helm@v4 with: version: v3.16.3 - - name: Bump both charts and tag + + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + + - name: The charts must already be at this version env: V: ${{ needs.validate.outputs.version }} run: | set -euo pipefail + fail=0 for c in eduide eduide-cluster; do - sed -i -E "s/^version: .*/version: ${V}/" "charts/$c/Chart.yaml" - sed -i -E "s/^appVersion: .*/appVersion: \"${V}\"/" "charts/$c/Chart.yaml" + cv=$(yq -r '.version' "charts/$c/Chart.yaml") + av=$(yq -r '.appVersion' "charts/$c/Chart.yaml") + if [[ "$cv" != "$V" ]]; then + echo "::error file=charts/$c/Chart.yaml::version is '$cv', expected '$V'" + fail=1 + fi + if [[ "$av" != "$V" ]]; then + echo "::error file=charts/$c/Chart.yaml::appVersion is '$av', expected '$V'" + fail=1 + fi done - git config user.name "eduide-release-train" - git config user.email "noreply@github.com" - git commit -qam "release: ${V}" + if [[ $fail -ne 0 ]]; then + echo "::error::Bump the charts in a reviewed pull request first, then re-run. See README: Cutting a release." + exit 1 + fi + echo "both charts are at $V" + + - name: Tag this repository + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail git tag -a "v${V}" -m "EduIDE ${V}" - git push origin HEAD:main "v${V}" - - name: Publish + git push origin "v${V}" + + - name: Publish both charts env: V: ${{ needs.validate.outputs.version }} run: | set -euo pipefail - echo '${{ secrets.GITHUB_TOKEN }}' | helm registry login ghcr.io -u '${{ github.actor }}' --password-stdin + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin mkdir -p dist for c in eduide-cluster eduide; do helm package "charts/$c" --destination dist diff --git "a/2,\n echo" "b/2,\n echo" deleted file mode 100644 index e69de29..0000000 diff --git a/AGENTS.md b/AGENTS.md index 1e4b4de..4d7160a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,13 @@ render-diff fills with false changes and stops being read. `Release train` cuts a version across all four repositories. It builds, then verifies every image exists and is multi-arch, and only then tags — because a failure after tagging strands immutable tags on repositories whose images were -never published. See `docs/releasing.md`. +never published. + +**It does not bump the chart version.** That is a reviewed pull request; the +workflow checks the charts are already at the requested version and fails +otherwise. Do not make automation push to `main`: it triggers the workflows +watching `main`. Full procedure in the README, and as a skill in +`.claude/skills/cut-a-release.md`. ## Conventions diff --git a/README.md b/README.md index b7701c0..57ea95b 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,169 @@ -# EduIDE Cloud Helm Charts +# EduIDE Helm Charts -This repository contains the helm charts for Theia Cloud. +The installable EduIDE platform. Two charts, published as OCI artifacts to +`ghcr.io/eduide/charts`. -There are three charts: +| Chart | Installed | Owns | +|---|---|---| +| `eduide-cluster` | once per **cluster** | CRDs, conversion webhook, ClusterRoles, cert-manager issuers | +| `eduide` | once per **environment** | operator, REST service, landing page, routes | -- `theia-cloud-base` installs cluster wide resources that may be used by multiple Theia Cloud installations -- `theia-cloud-crds` installs the custom resource definitions -- `theia-cloud` installs Theia Cloud itself and depends on `theia-cloud-base` and `theia-cloud-crds` +```bash +# once per cluster +helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster \ + --version 1.0.0-rc0 -n eduide-system --create-namespace + +# once per environment +helm install eduide oci://ghcr.io/eduide/charts/eduide \ + --version 1.0.0-rc0 -n test1 -f my-values.yaml +``` + +Both charts always carry the same version. `docs/charts.md` explains why the +split exists and what it is safe to change. + +Environment configuration for the TUM installations lives in +[EduIDE-deployment](https://github.com/EduIDE/EduIDE-deployment), not here. + +--- + +# Cutting a release + +A release is one version across four repositories: EduIDE-Cloud, EduIDE, +EduIDE-Landing-Page and this one. + +**The release train does not bump the chart version.** You do, in a reviewed +pull request. The workflow checks the charts are already at the version you +asked for and refuses to continue otherwise. Automation that pushes to `main` +triggers the workflows watching `main`, and release automation that can trigger +itself is a bad thing to own. + +## The three steps + +### 1. Dry run + +``` +Actions -> Release train -> Run workflow + version: 2.3.0 (no leading v) + dry_run: true +``` + +Nothing is built, tagged or published. It reports which images the version +would need. Read the summary before continuing. + +### 2. Bump the charts in a pull request + +Both charts, both fields, all four values identical: -## Release Model +```yaml +# charts/eduide/Chart.yaml AND charts/eduide-cluster/Chart.yaml +version: 2.3.0 +appVersion: "2.3.0" +``` -Released charts are published as OCI artifacts to `ghcr.io/eduide/charts`. -`theia-deployment` should consume those published chart versions directly for normal staging and production deployments. +```bash +docker run --rm -v "$PWD/charts:/helm-docs" -u "$(id -u)" jnorwood/helm-docs:v1.14.2 +``` -Pull requests also publish preview OCI chart versions using a `pr-` suffix on top of the chart version already present in `Chart.yaml`. -`theia-deployment` can consume those previews through a simple tag input such as `pr-123`. +Open the PR, let CI run, get it reviewed, merge. CI checks the version moved, +the READMEs match, and shows what the change does to every live environment. -## Cluster Prerequisites +### 3. Run the release train for real -The charts depend on well-established software in the Kubernetes ecosystem. Please make sure to install the dependencies before releasing with _helm_. +``` +Actions -> Release train -> Run workflow + version: 2.3.0 + dry_run: false +``` -- **cert-manager.io** is used for certificate management, supports internal/testing issuers and supports Let's Encrypt certificates. Installation instructions can be found [here](https://cert-manager.io), a helm chart [here](https://cert-manager.io/docs/installation/helm/). +It then, in this order: -- **Envoy Gateway** (Gateway API) is used to route traffic to Theia Cloud components. - Ensure Gateway API CRDs and Envoy Gateway are installed in your cluster and the GatewayClass name - matches `theia-cloud.gateway.className` (default: `envoy`). +1. **validates** the version is semver and `v2.3.0` is free +2. **builds** all 14 images at that tag, by dispatching each repository's own + build workflow — **nothing is tagged yet** +3. **verifies** every image exists in GHCR and is multi-arch +4. **tags** the three component repositories and creates their releases +5. **checks** the charts are at `2.3.0`, tags this repository, publishes both -You can find more information in the official [Theia Cloud documentation](https://theia-cloud.io/documentation/setuptheiacloud/). +Building before tagging is deliberate. Building 15 multi-GB IDE images is the +flakiest step in the pipeline; a failure after tagging strands immutable +`v2.3.0` tags on repositories whose images were never published. This way a +flake costs a re-run. -## Versioning +## Rolling it out -The chart `version` should get updated on every change/commit/PR.\ -However only changed charts should get an increased version, e.g. when a commit changes the theia-cloud chart, only this chart version has to be increased.\ -See below for more information: +The train deploys nothing. In EduIDE-deployment: ```yaml -# Releases -# follow semantic versioning (starting with release 0.9.0) -version: 1.0.0 - -# Pre-Releases -# append -next.X to the next version. X should be increased on every change/commit/PR -version: 1.0.0-next.0 -version: 1.0.0-next.1 +# environments/prod-tum/env.yaml +spec: + platform: + chartVersion: 2.3.0 # the only line that changes ``` -The `appVersion` is pointing to the `-next` tag this means that the images consumed are bound to change, when a new pre-release of that component is published. +Merge that, then run `Deploy environment`. Production is never deployed +automatically. + +## Release candidates -Therefore, you should only use full releases for deployments, as the next tag might change at any time. -If you still want to use a next version you should pin the used images to a specific version (`-next.`). +`2.3.0-rc.1` everywhere — same pipeline, same registry, marked as a prerelease +on GitHub. Consume with an explicit `--version`; never `--devel`. -### Release a new version +## Versioning rules -New release every three months. +| Thing | Form | Example | +|---|---|---| +| git tag | `vX.Y.Z` | `v2.3.0` | +| chart `version` and `appVersion` | `X.Y.Z` | `2.3.0` | +| image tag | `X.Y.Z` | `2.3.0` | -Provide a commit where the next parts are removed from the `version` and the `appVersion` fields of ALL charts. -Also set the images used in charts to the version of the release. -The release should be done after the main repository provided a release and the docker images were pushed. +`appVersion` and the image tag are the same string, so a chart says exactly +which images it runs. -With next change after a release needs the version number should be bumped and `-next.0/-next` should be added to the version/appVersion fields. -Furthermore, the new version, together with a release estimation date, should be added to the changelog. +The four repositories move together. A one-line landing page fix rebuilds +everything — that is the price of a version number that means something. If a +component's cadence ever diverges enough to hurt, the alternative is a bill of +materials pinning each component in the chart values, with `appVersion` demoted +to a label. -## How to generate Chart READMEs +## Checklist + +``` +[ ] dry run is clean +[ ] both Chart.yaml files at X.Y.Z, version AND appVersion +[ ] helm-docs run, READMEs committed +[ ] bump PR reviewed and merged +[ ] release train run with dry_run: false +[ ] chartVersion bumped in EduIDE-deployment for the environments to move +``` + +## If something goes wrong + +| Symptom | Cause | Fix | +|---|---|---| +| `version is 'X', expected 'Y'` | step 2 skipped or half-done | bump both charts, both fields | +| `missing ghcr.io/...` | a component build failed | check that repository's Actions, re-run | +| `is not multi-arch` | one architecture failed | re-run the whole build, not just the merge job | +| `tag v2.3.0 already exists` | version already used | pick the next one, tags are immutable | + +--- + +## Cluster prerequisites + +- **cert-manager** — certificate management, including Let's Encrypt. + [Install](https://cert-manager.io/docs/installation/helm/). +- **Envoy Gateway** with the Gateway API CRDs. The GatewayClass name must match + `gateway.className` (default `envoy`). + +## Working on the charts ```bash -docker pull jnorwood/helm-docs:latest && docker run --rm --volume "$(pwd)/charts:/helm-docs" -u $(id -u) jnorwood/helm-docs:latest +helm lint charts/eduide charts/eduide-cluster +./scripts/render-envs.sh /tmp/out # renders every real environment ``` -or run the `Rebuild READMEs` task. +CI also renders the PR base and head and posts the diff. **For a refactor the +expected result is an empty diff** — that is the acceptance criterion, not "it +still lints". + +See `AGENTS.md` for the things that catch people out, and +`.claude/skills/chart-change.md` for the loop to follow when editing a template. diff --git a/docs/releasing.md b/docs/releasing.md deleted file mode 100644 index f91624b..0000000 --- a/docs/releasing.md +++ /dev/null @@ -1,64 +0,0 @@ -# Releasing - -One platform version spans four repositories: EduIDE-Cloud, EduIDE, -EduIDE-Landing-Page and the charts here. The `Release train` workflow cuts all -of it. - -``` -Actions -> Release train -> Run workflow - version: 2.3.0 (no leading v) - dry_run: true leave this on the first time -``` - -## What it does, and why in that order - -1. **Validate** the version is semver and the tag is free. -2. **Build** every component at that tag, by dispatching each repository's own - build workflow with `image_tag`. **Nothing is tagged yet.** -3. **Verify** all 14 images exist in GHCR and are multi-arch. -4. **Tag** the three component repositories. -5. **Publish** both charts at that version. - -Tagging first is the obvious design and the wrong one. Building 15 multi-GB IDE -images is the flakiest step in the pipeline; if one fails after the tags exist, -immutable `vX.Y.Z` tags are stranded on repositories whose images were never -published. Building first makes a flake cost a re-run. - -Step 3 exists because a chart pinning a tag that was never pushed is a failure -that only shows up at deploy time, in whichever environment picks it up first. - -## Versions - -- Git tags are `vX.Y.Z`. -- Image tags and chart versions are `X.Y.Z`, so a chart's `appVersion` and the - image tag it refers to are the same string. -- Release candidates are `2.3.0-rc.1` throughout, published to the same place - and installed with an explicit `--version`. - -The four repositories move together. A one-line landing page fix therefore -rebuilds everything, which is the price of a version number that means -something. If a component's cadence ever diverges enough for that to hurt, the -alternative is a bill of materials pinning each component in the chart values, -with `appVersion` demoted to a label. - -## Rolling a release out - -The train does **not** deploy. It opens nothing and changes no environment. - -```yaml -# environments/prod-tum/env.yaml -spec: - platform: - chartVersion: 2.3.0 # the only line that changes -``` - -Merge that, then run `Deploy environment`. Production is never deployed -automatically. - -## swift - -`swift` is deliberately absent from the image list. It exists under `images/` -and in `docker-compose.images.yml`, and the README advertises it, but it is in -no build matrix and has never been published. Listing it would fail every -release; leaving it implicit would let the gap rot unnoticed, so it is named -in the workflow as an explicit exclusion instead. From 1d595a3978fca821a7388b6becc25f2357ccf40d Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 16:57:04 +0200 Subject: [PATCH 05/14] Chart 2.0.0: derive the app set, pin every image to a version Three things describe the same set of applications: the AppDefinition custom resources that make them deployable, the landing page list a student picks from, and the images preloaded onto every node. They were three hand-written lists across two repositories, the preload one addressed by array index, with production's one entry shorter than test's. Production offered c-templates while preloading everything except c-templates, so students picking it waited for a cold multi-gigabyte pull. appDefinitions.apps is now the only place any of it is written. Adding a language is one entry; the AppDefinitions, the landing page and the preloading DaemonSet all derive from it. scripts/test-app-consistency.sh asserts the three agree, and proves it catches the c-templates case. Versions. One knob per source repository, because they release on different cadences: versions.ide (EduIDE), versions.cloud (EduIDE-Cloud), versions.landingPage. versions.ide empty means the chart's appVersion, so `helm install --version 2.0.0` with no overrides pins every image to a released tag. The three image values are plain strings rendered through tpl, so they interpolate the versions block with no template change. Chart 2.0.0, appVersion 1.2.0. The test caught two things worth naming. The chart's default landing app was theia-cloud-demo, which no real environment installs - the page would have loaded with nothing selectable. And the garbage collector defaults to :latest and its repository has never cut a release, so a released chart would install whatever was built most recently and helm upgrade would see no diff when it changed; it is pinned to the commit latest pointed at, with a note to replace that once it releases. Also folded in what the umbrella's remaining subcharts provided: the admin API token Secret becomes a template here (the other three theia-certificates templates were disabled in every environment), and the shared cache and garbage collector become optional dependencies. That fixes the shared-cache rename bug on the way past - the umbrella still pinned theia-shared-cache 0.3.1 months after it became eduide-shared-cache 0.5.3. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/ci.yml | 3 + .gitignore | 4 + AGENTS.md | 35 ++++ README.md | 62 +++++-- charts/eduide-cluster/Chart.yaml | 4 +- charts/eduide-cluster/README.md | 2 +- charts/eduide/Chart.lock | 9 ++ charts/eduide/Chart.yaml | 25 ++- charts/eduide/README.md | 33 +++- charts/eduide/templates/_helpers.tpl | 61 +++++++ .../templates/admin-api-token-secret.yaml | 23 +++ charts/eduide/templates/appdefinitions.yaml | 100 ++++++++++++ charts/eduide/templates/image-preloading.yaml | 18 ++- .../templates/landing-page-config-map.yaml | 19 ++- charts/eduide/values.yaml | 153 ++++++++++++++++-- docs/charts.md | 4 +- scripts/test-app-consistency.sh | 115 +++++++++++++ 17 files changed, 624 insertions(+), 46 deletions(-) create mode 100644 charts/eduide/Chart.lock create mode 100644 charts/eduide/templates/admin-api-token-secret.yaml create mode 100644 charts/eduide/templates/appdefinitions.yaml create mode 100755 scripts/test-app-consistency.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dc7dae..7234365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: echo "::endgroup::" done + - name: App definitions, landing page and preloading agree + run: ./scripts/test-app-consistency.sh + - name: Chart version must be bumped when a chart changes if: github.event_name == 'pull_request' run: | diff --git a/.gitignore b/.gitignore index e43b0f9..d34bb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .DS_Store + +# Resolved by `helm dependency update`. Chart.lock is committed; the archives +# it pins are not - they are pulled at build and publish time. +charts/*/charts/ diff --git a/AGENTS.md b/AGENTS.md index 4d7160a..79b696d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,3 +89,38 @@ watching `main`. Full procedure in the README, and as a skill in `minItems: 1` on `spec.rules`, but the upstream Gateway API CRD does not, and `httproute-instances.yaml` ships `rules: []` deliberately for the operator to patch. + +## appDefinitions.apps is the single source of truth + +Three things derive from it: the AppDefinition custom resources, the app list +the landing page offers, and the images the preloading DaemonSet pulls onto +every node. **Never write a preload list, and never add an app in two places.** + +They used to be three hand-maintained lists across two repositories, the +preload one addressed by array index, with production's list one shorter than +test's. Production offered `c-templates` while preloading everything except +`c-templates`. `scripts/test-app-consistency.sh` asserts they agree and CI runs +it; it also fails on any floating tag in a default render. + +## One version knob per source repository + +`versions.ide` (EduIDE), `versions.cloud` (EduIDE-Cloud), `versions.landingPage` +(EduIDE-Landing-Page). `versions.ide` empty means the chart's `appVersion`, so +`helm install --version X` with no overrides pins every image to that release. + +A deploy override names exactly one. Never set a blanket tag - a pull request +only builds the images of the repo it came from, so the rest of the namespace +goes into `ImagePullBackOff`. + +The three image values are plain strings rendered through `tpl`, so they can +interpolate `.Values.versions.*` without any template change. Keep it that way: +turning them into `{registry, repository, tag}` maps would break every values +file for no gain. + +## The garbage collector is pinned to a commit, not a version + +Its repository has never cut a release - GHCR holds only `latest`, `main` and +per-commit SHAs - and its own chart defaults to `latest`. A released chart must +not install whatever was built most recently, and `helm upgrade` would see no +diff when it changed. `garbageCollector.image.tag` therefore pins a commit SHA. +Replace it with a semver tag when that repo starts releasing. diff --git a/README.md b/README.md index 57ea95b..39cb244 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,51 @@ The installable EduIDE platform. Two charts, published as OCI artifacts to ```bash # once per cluster helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster \ - --version 1.0.0-rc0 -n eduide-system --create-namespace + --version 2.0.0 -n eduide-system --create-namespace # once per environment helm install eduide oci://ghcr.io/eduide/charts/eduide \ - --version 1.0.0-rc0 -n test1 -f my-values.yaml + --version 2.0.0 -n eduide-test1 -f my-values.yaml ``` Both charts always carry the same version. `docs/charts.md` explains why the split exists and what it is safe to change. +## What one install pins + +A bare `--version 2.0.0` with no overrides pins every image. Nothing floats. + +| Value | Source repository | Default | +|---|---|---| +| `versions.ide` | EduIDE (the IDE images) | empty → the chart's `appVersion` | +| `versions.cloud` | EduIDE-Cloud (operator, REST service) | `1.2.0` | +| `versions.landingPage` | EduIDE-Landing-Page | `1.2.0` | + +The three release on their own cadence, so each has its own knob and an +override names exactly one. A blanket tag would be wrong: a pull request only +builds the images of the repo it came from, so pointing everything at `pr-451` +puts the rest of the namespace into `ImagePullBackOff`. + +## Adding a language + +One entry in `appDefinitions.apps`: + +```yaml +appDefinitions: + apps: + haskell-latest: + image: eduide/haskell # tag comes from versions.ide + landingPage: + label: Haskell # omit this key to deploy it but hide it +``` + +That single map drives the AppDefinition custom resource, the landing page's +app list, and the set of images preloaded onto every node. It used to be three +hand-maintained lists across two repositories, the preload one addressed by +array index - which is how production came to offer `c-templates` while +preloading everything except `c-templates`. +`scripts/test-app-consistency.sh` asserts the three agree, and CI runs it. + Environment configuration for the TUM installations lives in [EduIDE-deployment](https://github.com/EduIDE/EduIDE-deployment), not here. @@ -113,23 +148,26 @@ on GitHub. Consume with an explicit `--version`; never `--devel`. | Thing | Form | Example | |---|---|---| | git tag | `vX.Y.Z` | `v2.3.0` | -| chart `version` and `appVersion` | `X.Y.Z` | `2.3.0` | -| image tag | `X.Y.Z` | `2.3.0` | +| chart `version` | `X.Y.Z` | `2.0.0` | +| chart `appVersion` | the EduIDE IDE image version | `1.2.0` | +| image tag | `X.Y.Z` | `1.2.0` | -`appVersion` and the image tag are the same string, so a chart says exactly -which images it runs. +`appVersion` is the tag of the IDE images, so a chart says exactly which IDEs it +runs. The operator, REST service and landing page carry their own versions in +`versions.cloud` and `versions.landingPage`, because they release on a different +cadence and a one-line landing page fix should not rebuild fifteen +multi-gigabyte IDE images. -The four repositories move together. A one-line landing page fix rebuilds -everything — that is the price of a version number that means something. If a -component's cadence ever diverges enough to hurt, the alternative is a bill of -materials pinning each component in the chart values, with `appVersion` demoted -to a label. +The chart version is the platform version and is what an environment pins. ## Checklist ``` [ ] dry run is clean -[ ] both Chart.yaml files at X.Y.Z, version AND appVersion +[ ] both Chart.yaml files at the same `version` +[ ] `appVersion` set to the EduIDE release the IDE images were published under +[ ] `versions.cloud` and `versions.landingPage` set to their releases +[ ] scripts/test-app-consistency.sh passes (no floating tags, three consumers agree) [ ] helm-docs run, READMEs committed [ ] bump PR reviewed and merged [ ] release train run with dry_run: false diff --git a/charts/eduide-cluster/Chart.yaml b/charts/eduide-cluster/Chart.yaml index ad02360..1332c33 100644 --- a/charts/eduide-cluster/Chart.yaml +++ b/charts/eduide-cluster/Chart.yaml @@ -4,5 +4,5 @@ description: | Cluster-scoped half of EduIDE: CRDs, the conversion webhook, ClusterRoles and cert-manager issuers. Install once per cluster, before any eduide release. type: application -version: 1.0.0-rc0 -appVersion: "1.0.0-rc0" +version: 2.0.0 +appVersion: "1.2.0" diff --git a/charts/eduide-cluster/README.md b/charts/eduide-cluster/README.md index c1b9815..2fa3a00 100644 --- a/charts/eduide-cluster/README.md +++ b/charts/eduide-cluster/README.md @@ -1,6 +1,6 @@ # eduide-cluster -![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.0.0-rc0](https://img.shields.io/badge/AppVersion-1.0.0--rc0-informational?style=flat-square) +![Version: 2.0.0](https://img.shields.io/badge/Version-2.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0](https://img.shields.io/badge/AppVersion-1.2.0-informational?style=flat-square) Cluster-scoped half of EduIDE: CRDs, the conversion webhook, ClusterRoles and cert-manager issuers. Install once per cluster, before any eduide release. diff --git a/charts/eduide/Chart.lock b/charts/eduide/Chart.lock new file mode 100644 index 0000000..a14486a --- /dev/null +++ b/charts/eduide/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: eduide-shared-cache + repository: oci://ghcr.io/eduide/charts + version: 0.5.3 +- name: theia-workspace-garbage-collector + repository: oci://ghcr.io/eduide/charts + version: 0.1.0 +digest: sha256:65d9bdb261ed364cfd8b3c15075a6fb2f0ab2605d7f1c03147be1b0c1e84a654 +generated: "2026-08-26T16:52:06.254801+02:00" diff --git a/charts/eduide/Chart.yaml b/charts/eduide/Chart.yaml index 9aa18eb..d54c2f1 100644 --- a/charts/eduide/Chart.yaml +++ b/charts/eduide/Chart.yaml @@ -15,9 +15,30 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.0-rc0 +version: 2.0.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "1.4.0-next" +appVersion: "1.2.0" + +# The two components that release on their own cadence and are genuinely +# optional. Everything else the chart needs is a template in this chart, not a +# dependency - vendoring the rest is what removed the three-way Chart.lock / +# Chart.yaml / HEAD version drift. +# +# eduide-shared-cache was called theia-shared-cache until 0.5.0. The umbrella it +# replaces still pinned theia-shared-cache 0.3.1, months after the rename, which +# is why the name is spelled out here with a current version. +dependencies: + - name: eduide-shared-cache + alias: sharedCache + version: "0.5.3" + repository: "oci://ghcr.io/eduide/charts" + condition: sharedCache.enabled + - name: theia-workspace-garbage-collector + alias: garbageCollector + version: "0.1.0" + repository: "oci://ghcr.io/eduide/charts" + condition: garbageCollector.enabled + diff --git a/charts/eduide/README.md b/charts/eduide/README.md index 912d910..7c14306 100644 --- a/charts/eduide/README.md +++ b/charts/eduide/README.md @@ -1,6 +1,6 @@ # eduide -![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.4.0-next](https://img.shields.io/badge/AppVersion-1.4.0--next-informational?style=flat-square) +![Version: 2.0.0](https://img.shields.io/badge/Version-2.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0](https://img.shields.io/badge/AppVersion-1.2.0-informational?style=flat-square) EduIDE tenant release: operator, REST service, landing page and routes for one environment. Requires eduide-cluster to be installed on the cluster first. @@ -8,6 +8,13 @@ environment. Requires eduide-cluster to be installed on the cluster first. *This chart was tested with Helm version v3.17.0.* *Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* +## Requirements + +| Repository | Name | Version | +|------------|------|---------| +| oci://ghcr.io/eduide/charts | sharedCache(eduide-shared-cache) | 0.5.3 | +| oci://ghcr.io/eduide/charts | garbageCollector(theia-workspace-garbage-collector) | 0.1.0 | + ## Values | Key | Type | Default | Description | @@ -15,6 +22,9 @@ environment. Requires eduide-cluster to be installed on the cluster first. | app | object | (see details below) | General information about the deployed app | | app.id | Deprecated | `"asdfghjkl"` | The app id which is used in the communication between website and REST-API as a spam migitation. This id is public. Please choose an random generated string. Use service.authToken instead. | | app.name | string | `"Theia Blueprint"` | The name of the application that may be displayed e.g. on the landing pages | +| appDefinitions | object | (see details below) | The IDE applications this installation offers. This map is the single source of truth for three things that used to be configured separately and drifted apart: the AppDefinition custom resources, the app list the landing page shows, and the set of images preloaded onto every node. Adding a language is one entry here, not three edits in two repositories. Each key is the AppDefinition name. `image` is a repository without a tag - the tag comes from versions.ide (or the chart's appVersion), so a release moves every IDE image at once. An entry with a `landingPage` key is offered in the landing page drop-down; one without is deployable but hidden. | +| appDefinitions.apps | object | `{"c-latest":{"image":"eduide/c","landingPage":{"label":"C"}},"c-templates-latest":{"image":"eduide/c-templates","landingPage":{"label":"C (Templates)"}},"java-17-latest":{"image":"eduide/java-17","landingPage":{"label":"Java 17"},"limitsMemory":"3000M","minInstances":3,"requestsCpu":"500m"},"java-17-templates-latest":{"image":"eduide/java-17-templates","landingPage":{"label":"Java 17 (Templates)"},"limitsMemory":"3000M","requestsCpu":"500m"},"javascript-latest":{"image":"eduide/javascript","landingPage":{"label":"JavaScript"}},"ocaml-latest":{"image":"eduide/ocaml","landingPage":{"label":"OCaml"}},"python-latest":{"image":"eduide/python","landingPage":{"label":"Python"}},"rust-latest":{"image":"eduide/rust","landingPage":{"label":"Rust"}}}` | The applications. Key is the AppDefinition name. | +| appDefinitions.defaults | object | `{"downlinkLimit":30000,"imagePullPolicy":"IfNotPresent","limitsCpu":"2","limitsMemory":"2400M","maxInstances":1000,"minInstances":0,"mountPath":"/home/project","options":{"dataBridgeEnabled":"true","dataBridgePort":"16281"},"port":3000,"requestsCpu":"200m","requestsMemory":"500M","timeout":1440,"uid":101,"uplinkLimit":30000}` | Applied to every app that does not state its own. Only the four scaling and sizing values genuinely differ between languages. | | demoApplication | object | (see details below) | Information about the demo application to be installed | | demoApplication.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the main application's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | demoApplication.install | bool | `true` | Should the demo application be installed | @@ -26,6 +36,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | demoApplication.name | string | `"theiacloud/theia-cloud-demo:1.2.0-next"` | The name of docker image to be used | | demoApplication.pullSecret | string | `""` | the image pull secret. Leave empty if registry is public | | demoApplication.timeout | string | `"30"` | Limit in minutes | +| garbageCollector | object | `{"enabled":true,"image":{"tag":"599557839e5c5893eb0c20785dac671ae70f7e8a"}}` | Reaps workspaces whose sessions are long gone. | | gateway | object | `{"className":"envoy","create":true,"enabled":true,"httpEnabled":false,"httpPort":80,"httpsPort":443,"instancesRouteName":"theia-cloud-demo-ws-route","instancesWildcardSecretNames":{},"name":"theia-cloud-gateway","parentRefs":[],"routes":{"enabled":true},"serviceRouteRequestTimeout":"60s","tls":true}` | Gateway API configuration (Envoy Gateway by default) | | gateway.className | string | `"envoy"` | GatewayClassName to use (Envoy Gateway default is typically "envoy") | | gateway.create | bool | `true` | Whether to render a Gateway resource in the release namespace. Set to false when using a centralized shared Gateway in another namespace. | @@ -53,6 +64,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | hosts.configuration.landing | string | `"trynow"` | afix of the landing page | | hosts.configuration.service | string | `"servicex"` | afix of the REST service | | imagePullPolicy | string | `"Always"` | The default imagePullPolicy for containers of theia cloud. Can be overridden for individual components by specifying the imagePullPolicy variable there. Possible values: - Always - IfNotPresent - Never | +| imageRegistry | string | `"ghcr.io/eduide"` | The container registry every EduIDE image is pulled from. | | keycloak | object | (see details below) | Values related to Keycloak | | keycloak.adminGroup | string | `"theia-cloud/admin"` | The name of the Keycloak group identifying admin users who are allowed to access the service's admin endpoints. | | keycloak.authUrl | string | `"https://keycloak.url/auth/"` | Key cloak auth URL. Only has to be specified when enable: true | @@ -63,12 +75,12 @@ environment. Requires eduide-cluster to be installed on the cluster first. | keycloak.realm | string | `"TheiaCloud"` | The Keycloak Realm. Only has to be specified when enable: true | | landingPage | object | (see details below) | Values related to the landing page | | landingPage.additionalApps | string | `nil` | The page may show these additional apps in a drop down. This is a map. The key maps to the app definition name The value contains the label shown in the UI and may optionally contain an image override that is forwarded to the landing page config. Example: different-app-definition: label: "Different App Definition" image: "different-app-definition" visible: false further-app-definition: label: "Further App Definition" | -| landingPage.appDefinition | string | `"theia-cloud-demo"` | the app id to launch | +| landingPage.appDefinition | string | `"java-17-templates-latest"` | the app id to launch | | landingPage.disableInfo | bool | `false` | Should showing info title and text below the launch button be disabled true hides the info title and text false shows the info title and text | | landingPage.enabled | bool | `true` | Whether the landing page shall be enabled | | landingPage.ephemeralStorage | bool | `true` | If set to true no persisted storage is used when creating sessions on the landing page. Set to false if you want to use persisted storage. | | landingPage.footerLinks | string | (see details below) | Optional: Customize footer links on the landing page All footer link configurations are optional. If not provided, default values will be used. | -| landingPage.image | string | `"theiacloud/theia-cloud-landing-page:1.2.0-next"` | the landing page image to use | +| landingPage.image | string | `"{{ .Values.imageRegistry }}/eduidec-landing-page:{{ .Values.versions.landingPage }}"` | the landing page image to use. Templated, so the tag follows versions.landingPage unless the whole string is overridden. | | landingPage.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the landing page's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | landingPage.imagePullSecret | string | `nil` | Optional: the image pull secret | | landingPage.infoText | string | `nil` | Optional: If specified with a value, this overrides the info text shown on the landing page. Empty values are ignored. Use `disableInfo` to deactivate showing the info completely. | @@ -99,7 +111,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | operator.dependencyCache.enabled | bool | `false` | Whether to enable the dependency cache | | operator.dependencyCache.url | string | `""` | The URL of the dependency cache server. | | operator.eagerStart | bool | `false` | Whether theia applications shall be started eager. This means that the application is already running without a user. When a user requests a new session, one of the already launched ones is assigned. Currently only false is fully supported. | -| operator.image | string | `"theiacloud/theia-cloud-operator:1.2.0-next"` | The operator image | +| operator.image | string | `"{{ .Values.imageRegistry }}/eduide-cloud/operator:{{ .Values.versions.cloud }}"` | The operator image. Templated, so the tag follows versions.cloud unless the whole string is overridden. | | operator.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the operator's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | operator.imagePullSecret | string | `nil` | Optional: the image pull secret | | operator.leaderElection | object | (see details below) | Options to influence the operator's leader election | @@ -115,13 +127,15 @@ environment. Requires eduide-cluster to be installed on the cluster first. | operator.wondershaperImage | string | `"theiacloud/theia-cloud-wondershaper:1.2.0-next"` | If bandwidthLimiter is set to WONDERSHAPER or K8SANNOTATIONANDWONDERSHAPER this image will be used for the wondershaper init container | | operatorrole.name | string | `"operator-api-access"` | | | preloading | object | (see details below) | Values to configure preloading of images on Kubernetes nodes. | +| preloading.deriveFromApps | bool | `true` | Set to false to preload only preloading.images and nothing derived. | | preloading.enable | bool | `true` | Is image preloading enabled. | | preloading.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the image preloading containers. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | -| preloading.images | list | `[]` | Images to preload. Each item is either an image reference string or a map: `{ image: "...", args: ["--version"] }` to use the image entrypoint (distroless-friendly), or `{ image: "...", command: [...], args: [...] }` for a full override. If only strings are used, the chart runs `/bin/sh -c 'echo …; exit 0'` (shell required in the image). If the list is empty and demoApplication.install == true, demoApplication.name is automatically added. | +| preloading.images | list | `[]` | Extra images to preload, on top of the ones derived automatically. Leave this empty. The chart preloads every appDefinitions.apps image, every sidecar image and the landing page image without being told, so the list cannot fall out of step with what the installation actually offers. It used to be written out by hand per environment and addressed by array index, which is how production ended up offering c-templates while preloading everything except c-templates. Each item is either an image reference string or a map: `{ image: "...", args: ["--version"] }` to use the image entrypoint (distroless-friendly), or `{ image: "...", command: [...], args: [...] }` for a full override. If only strings are used, the chart runs `/bin/sh -c 'echo …; exit 0'` (shell required in the image). | | service | object | (see details below) | Values of the Theia Cloud REST service | -| service.adminApiTokenSecret | object | `{"key":"ADMIN_API_TOKEN","name":"service-admin-api-token"}` | Reference to an existing Kubernetes Secret containing the bearer token for admin API token protected endpoints. The chart does not create or manage this Secret. | +| service.adminApiToken | string | `""` | Base64-encoded admin API token. Only read when adminApiTokenSecret.create is true. Comes from a deployment secret, never from a file in git. | +| service.adminApiTokenSecret | object | `{"create":false,"key":"ADMIN_API_TOKEN","name":"service-admin-api-token"}` | The Kubernetes Secret holding the bearer token for admin API token protected endpoints. Set `create: true` and supply `adminApiToken` to have the chart manage it, or leave `create: false` and reference one you created yourself. | | service.authToken | string | `"asdfghjkl"` | The service authentication token used in the communication between website and REST-API for spam mitigation. This token is public. Please choose a random generated string. | -| service.image | string | `"theiacloud/theia-cloud-service:1.2.0-next"` | The image to use | +| service.image | string | `"{{ .Values.imageRegistry }}/eduide-cloud/service:{{ .Values.versions.cloud }}"` | The image to use. Templated, so the tag follows versions.cloud unless the whole string is overridden. | | service.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the service's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | service.imagePullSecret | string | `nil` | Optional: the image pull secret | | service.port | int | `8081` | service port (default: 8081) | @@ -129,7 +143,12 @@ environment. Requires eduide-cluster to be installed on the cluster first. | service.sentry | object | (see details below) | Values related to Sentry on the service. | | service.sentry.enable | bool | `true` | Whether to set SENTRY_ENABLE=true in the service deployment. | | servicerole.name | string | `"service-api-access"` | | +| sharedCache | object | `{"enabled":false}` | The Gradle build cache and Maven proxy. Optional: nothing reaches it unless operator.enableBuildCaching or operator.enableDependencyCaching is also turned on, so enabling this alone deploys a cache with no clients. | | skipPreflight | bool | `false` | Skip the check that eduide-cluster is installed on this cluster. Only useful for rendering against a cluster that intentionally lacks it. | +| versions | object | (see details below) | Image versions, one per source repository. Every image the chart deploys derives its tag from one of these three, so a release is three numbers rather than nineteen image strings scattered across environment values files. | +| versions.cloud | string | `"1.2.0"` | EduIDE-Cloud: the operator and the REST service. Released independently of the IDE images, so it carries its own version. | +| versions.ide | string | `""` | The IDE images from the EduIDE repository (java-17, c, python, ...). Empty falls through to the chart's appVersion, which is what a release sets, so a plain `helm install --version 2.0.0` pins every IDE image to the tag that release published. | +| versions.landingPage | string | `"1.2.0"` | EduIDE-Landing-Page. Released independently as well. | ---------------------------------------------- Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/eduide/templates/_helpers.tpl b/charts/eduide/templates/_helpers.tpl index 4ef5360..1275b0a 100644 --- a/charts/eduide/templates/_helpers.tpl +++ b/charts/eduide/templates/_helpers.tpl @@ -55,3 +55,64 @@ section name, so non-alphanumerics collapse to hyphens and it is capped at 63. {{- define "theia-cloud.gateway.wildcardListenerName" -}} {{- printf "https-%s" (regexReplaceAll "[^a-zA-Z0-9-]" .wildcard "-") | trunc 63 | trimSuffix "-" -}} {{- end -}} + +{{/* +The tag every IDE image carries. versions.ide wins if set, otherwise the +chart's appVersion, so `helm install --version 2.0.0` with no overrides pins +every IDE image to the tag that release published. +*/}} +{{- define "eduide.ideTag" -}} +{{- .Values.versions.ide | default .Chart.AppVersion -}} +{{- end -}} + +{{/* +One app's fully qualified image. `image` is a bare repository; a value that +already carries a tag or a digest is passed through untouched so an environment +can pin one app without restructuring anything. +Call with (dict "app" $app "ctx" $). +*/}} +{{- define "eduide.appImage" -}} +{{- $app := .app -}} +{{- $ctx := .ctx -}} +{{- $repo := $app.image | toString -}} +{{- if or (contains "@sha256:" $repo) (regexMatch ".*:[^/]+$" $repo) -}} +{{- $repo -}} +{{- else -}} +{{- $tag := $app.imageTag | default (include "eduide.ideTag" $ctx) -}} +{{- if contains "/" $repo -}} +{{- if hasPrefix $ctx.Values.imageRegistry $repo -}} +{{- printf "%s:%s" $repo $tag -}} +{{- else -}} +{{- printf "%s/%s:%s" $ctx.Values.imageRegistry $repo $tag -}} +{{- end -}} +{{- else -}} +{{- printf "%s/%s:%s" $ctx.Values.imageRegistry $repo $tag -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Every image this installation needs on every node: one per app, one per +sidecar, plus the landing page. Derived rather than listed, so it cannot +disagree with what the installation offers. +*/}} +{{- define "eduide.preloadImages" -}} +{{- $out := list -}} +{{- if .Values.preloading.deriveFromApps -}} +{{- if .Values.landingPage.enabled -}} +{{- $out = append $out (tpl (.Values.landingPage.image | toString) .) -}} +{{- end -}} +{{- $ctx := . -}} +{{- range $name := (.Values.appDefinitions.apps | default dict | keys | sortAlpha) -}} +{{- $app := index $ctx.Values.appDefinitions.apps $name -}} +{{- $out = append $out (include "eduide.appImage" (dict "app" $app "ctx" $ctx)) -}} +{{- range $sc := ($app.sidecars | default list) -}} +{{- $out = append $out (include "eduide.appImage" (dict "app" $sc "ctx" $ctx)) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- range $extra := (.Values.preloading.images | default list) -}} +{{- $out = append $out $extra -}} +{{- end -}} +{{- $out | toJson -}} +{{- end -}} diff --git a/charts/eduide/templates/admin-api-token-secret.yaml b/charts/eduide/templates/admin-api-token-secret.yaml new file mode 100644 index 0000000..e10012b --- /dev/null +++ b/charts/eduide/templates/admin-api-token-secret.yaml @@ -0,0 +1,23 @@ +{{- /* +The bearer token the admin scaling API checks. The service template already +references this Secret by name; before 2.0.0 it was created by a separate +theia-certificates chart, whose other three templates were disabled in every +environment. Carrying one Secret is not worth a chart. + +The token itself comes from the environment's GitHub Environment secret, never +from a values file in git. +*/ -}} +{{- if .Values.service.adminApiTokenSecret.create }} +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: {{ .Values.service.adminApiTokenSecret.name }} + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: service +data: + {{ .Values.service.adminApiTokenSecret.key }}: {{ required "service.adminApiTokenSecret.create is true but service.adminApiToken is empty" .Values.service.adminApiToken | quote }} +{{- end }} diff --git a/charts/eduide/templates/appdefinitions.yaml b/charts/eduide/templates/appdefinitions.yaml new file mode 100644 index 0000000..b219de0 --- /dev/null +++ b/charts/eduide/templates/appdefinitions.yaml @@ -0,0 +1,100 @@ +{{- /* +One AppDefinition per entry in appDefinitions.apps. The same map drives the +landing page's app list and the preloading DaemonSet, so the three cannot +disagree. + +Sorted by name so the rendered output is stable and a helm diff shows real +changes rather than reordering. +*/ -}} +{{- $ctx := . -}} +{{- $d := .Values.appDefinitions.defaults | default dict -}} +{{- range $name := (.Values.appDefinitions.apps | default dict | keys | sortAlpha) }} +{{- $app := index $ctx.Values.appDefinitions.apps $name }} +{{- if not $app.image }} +{{- fail (printf "appDefinitions.apps.%s has no image" $name) }} +{{- end }} +{{- /* +minInstances and maxInstances are bootstrap defaults only. Both are required by +the CRD and a CEL rule enforces min <= max, so they cannot simply be omitted; +but the admin scaling API mutates them on the live resource, and Helm must not +reset that on every upgrade. The lookup reads back what is there. It returns +empty under `helm template`, which is why both fields are masked in the render +diff - see AGENTS.md. +*/ -}} +{{- $live := (lookup "theia.cloud/v1beta11" "AppDefinition" $ctx.Release.Namespace $name).spec | default dict }} +{{- $min := $app.minInstances | default $d.minInstances | default 0 }} +{{- if hasKey $live "minInstances" }}{{ $min = get $live "minInstances" }}{{ end }} +{{- $max := $app.maxInstances | default $d.maxInstances | default 10 }} +{{- if hasKey $live "maxInstances" }}{{ $max = get $live "maxInstances" }}{{ end }} +--- +apiVersion: theia.cloud/v1beta11 +kind: AppDefinition +metadata: + name: {{ $name }} + labels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/instance: {{ $ctx.Release.Name }} + app.kubernetes.io/managed-by: {{ $ctx.Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: app-definition + annotations: + helm.sh/revision: {{ $ctx.Release.Revision | quote }} +spec: + name: {{ $name }} + image: {{ include "eduide.appImage" (dict "app" $app "ctx" $ctx) }} + imagePullPolicy: {{ $app.imagePullPolicy | default $d.imagePullPolicy | default "IfNotPresent" }} + uid: {{ $app.uid | default $d.uid | default 101 }} + port: {{ $app.port | default $d.port | default 3000 }} + {{- /* The route this app's sessions are exposed through. Taken from the + gateway values rather than repeated, so renaming the route cannot orphan an + AppDefinition. */}} + ingressname: {{ tpl ($ctx.Values.gateway.instancesRouteName | toString) $ctx }} + ingressHostnamePrefixes: + {{- range ($app.ingressHostnamePrefixes | default $d.ingressHostnamePrefixes | default (list "*.webview.")) }} + - {{ . | quote }} + {{- end }} + minInstances: {{ $min }} + maxInstances: {{ $max }} + requestsMemory: {{ $app.requestsMemory | default $d.requestsMemory }} + requestsCpu: {{ $app.requestsCpu | default $d.requestsCpu }} + limitsMemory: {{ $app.limitsMemory | default $d.limitsMemory }} + limitsCpu: {{ $app.limitsCpu | default $d.limitsCpu | quote }} + downlinkLimit: {{ $app.downlinkLimit | default $d.downlinkLimit | default 30000 }} + uplinkLimit: {{ $app.uplinkLimit | default $d.uplinkLimit | default 30000 }} + mountPath: {{ $app.mountPath | default $d.mountPath | default "/home/project" }} + timeout: {{ $app.timeout | default $d.timeout | default 1440 }} + monitor: + port: {{ (($app.monitor).port) | default (($d.monitor).port) | default 3000 }} + activityTracker: + timeoutAfter: {{ ((($app.monitor).activityTracker).timeoutAfter) | default ((($d.monitor).activityTracker).timeoutAfter) | default 60 }} + notifyAfter: {{ ((($app.monitor).activityTracker).notifyAfter) | default ((($d.monitor).activityTracker).notifyAfter) | default 55 }} + {{- with ($app.options | default $d.options) }} + options: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $app.sidecars }} + sidecars: + {{- range . }} + - name: {{ .name }} + image: {{ include "eduide.appImage" (dict "app" . "ctx" $ctx) }} + port: {{ .port | default 5000 }} + {{- with .languages }} + languages: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .cpuLimit }} + cpuLimit: {{ . }} + {{- end }} + {{- with .memoryLimit }} + memoryLimit: {{ . }} + {{- end }} + {{- with .cpuRequest }} + cpuRequest: {{ . }} + {{- end }} + {{- with .memoryRequest }} + memoryRequest: {{ . }} + {{- end }} + mountWorkspace: {{ if hasKey . "mountWorkspace" }}{{ .mountWorkspace }}{{ else }}true{{ end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/eduide/templates/image-preloading.yaml b/charts/eduide/templates/image-preloading.yaml index fc02fb9..57c6ca2 100644 --- a/charts/eduide/templates/image-preloading.yaml +++ b/charts/eduide/templates/image-preloading.yaml @@ -1,16 +1,18 @@ {{ if .Values.preloading.enable -}} {{- /* -If the image preloading list is empty and the demo application is installed, -add the demo application image to the list. -If the list is not empty, it was explicitly overridden by the user and used as is. -Initialize the $images variable because otherwise it is scoped to the if/else +The list is derived from appDefinitions.apps, their sidecars and the landing +page, with preloading.images appended. Nothing has to be listed by hand, so the +preloaded set cannot disagree with what the installation actually offers - the +old hand-written, index-addressed list is why production offered c-templates +while preloading everything except c-templates. */ -}} -{{- $images := list -}} -{{- if and (not .Values.preloading.images) .Values.demoApplication.install -}} +{{- $images := include "eduide.preloadImages" . | fromJsonArray -}} +{{- if and (not $images) .Values.demoApplication.install -}} {{- $images = list .Values.demoApplication.name -}} -{{- else -}} - {{- $images = .Values.preloading.images -}} +{{- end -}} +{{- if not $images -}} +{{- fail "preloading.enable is true but no images resolved: set appDefinitions.apps, or preloading.images, or disable preloading" -}} {{- end -}} {{- /* Set image pull policy to local variable for increase readability. */ -}} diff --git a/charts/eduide/templates/landing-page-config-map.yaml b/charts/eduide/templates/landing-page-config-map.yaml index b05094e..740cd0a 100644 --- a/charts/eduide/templates/landing-page-config-map.yaml +++ b/charts/eduide/templates/landing-page-config-map.yaml @@ -25,8 +25,25 @@ data: {{- end }} serviceUrl: "{{ include "theia-cloud.url.service" . }}", appDefinition: "{{ tpl (.Values.landingPage.appDefinition | toString) . }}", + {{- /* + The app list is derived from appDefinitions.apps: every entry carrying a + `landingPage` key is offered, in the order it is declared there. That is + the same map the AppDefinitions and the preloading DaemonSet come from, so + the page cannot advertise an app that was never deployed - which is what + happened when this was a separate hand-maintained map. + landingPage.additionalApps still wins if set, as an escape hatch. + */}} + {{- $apps := .Values.landingPage.additionalApps }} + {{- if not $apps }} + {{- $apps = dict }} + {{- $ctx := . }} + {{- range $n := (.Values.appDefinitions.apps | default dict | keys | sortAlpha) }} + {{- $a := index $ctx.Values.appDefinitions.apps $n }} + {{- if $a.landingPage }}{{ $apps = set $apps $n $a.landingPage }}{{ end }} + {{- end }} + {{- end }} additionalApps: [ - {{- range $key, $val := .Values.landingPage.additionalApps }} + {{- range $key, $val := $apps }} {{- $image := (get $val "image" | default (get $val "Image")) }} { serviceAuthToken: {{ $key | quote}}, diff --git a/charts/eduide/values.yaml b/charts/eduide/values.yaml index 5a13344..4bdcc7b 100644 --- a/charts/eduide/values.yaml +++ b/charts/eduide/values.yaml @@ -1,3 +1,22 @@ +# -- Image versions, one per source repository. Every image the chart deploys +# derives its tag from one of these three, so a release is three numbers rather +# than nineteen image strings scattered across environment values files. +# @default -- (see details below) +versions: + # -- The IDE images from the EduIDE repository (java-17, c, python, ...). + # Empty falls through to the chart's appVersion, which is what a release sets, + # so a plain `helm install --version 2.0.0` pins every IDE image to the tag + # that release published. + ide: "" + # -- EduIDE-Cloud: the operator and the REST service. Released independently + # of the IDE images, so it carries its own version. + cloud: "1.2.0" + # -- EduIDE-Landing-Page. Released independently as well. + landingPage: "1.2.0" + +# -- The container registry every EduIDE image is pulled from. +imageRegistry: ghcr.io/eduide + # -- The default imagePullPolicy for containers of theia cloud. # Can be overridden for individual components by specifying the imagePullPolicy variable there. # Possible values: @@ -72,8 +91,9 @@ hosts: landingPage: # -- Whether the landing page shall be enabled enabled: true - # -- the landing page image to use - image: theiacloud/theia-cloud-landing-page:1.2.0-next + # -- the landing page image to use. Templated, so the tag follows + # versions.landingPage unless the whole string is overridden. + image: '{{ .Values.imageRegistry }}/eduidec-landing-page:{{ .Values.versions.landingPage }}' # -- Optional: Override the imagePullPolicy for the landing page's docker image. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: @@ -85,7 +105,7 @@ landingPage: # -- Whether to set SENTRY_ENABLE=true in the landing page deployment. enable: true # -- the app id to launch - appDefinition: "theia-cloud-demo" + appDefinition: "java-17-templates-latest" # -- If set to true no persisted storage is used when creating sessions on the landing page. # Set to false if you want to use persisted storage. ephemeralStorage: true @@ -245,8 +265,9 @@ oauth2Proxy: # -- Values related to the operator # @default -- (see details below) operator: - # -- The operator image - image: theiacloud/theia-cloud-operator:1.2.0-next + # -- The operator image. Templated, so the tag follows versions.cloud unless + # the whole string is overridden. + image: '{{ .Values.imageRegistry }}/eduide-cloud/operator:{{ .Values.versions.cloud }}' # -- Optional: Override the imagePullPolicy for the operator's docker image. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: @@ -346,13 +367,20 @@ service: # -- The service authentication token used in the communication between website and REST-API # for spam mitigation. This token is public. Please choose a random generated string. authToken: asdfghjkl - # -- Reference to an existing Kubernetes Secret containing the bearer token for admin API token protected endpoints. - # The chart does not create or manage this Secret. + # -- The Kubernetes Secret holding the bearer token for admin API token + # protected endpoints. Set `create: true` and supply `adminApiToken` to have + # the chart manage it, or leave `create: false` and reference one you created + # yourself. adminApiTokenSecret: + create: false name: service-admin-api-token key: ADMIN_API_TOKEN - # -- The image to use - image: theiacloud/theia-cloud-service:1.2.0-next + # -- Base64-encoded admin API token. Only read when adminApiTokenSecret.create + # is true. Comes from a deployment secret, never from a file in git. + adminApiToken: "" + # -- The image to use. Templated, so the tag follows versions.cloud unless + # the whole string is overridden. + image: '{{ .Values.imageRegistry }}/eduide-cloud/service:{{ .Values.versions.cloud }}' # -- Optional: Override the imagePullPolicy for the service's docker image. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: @@ -420,21 +448,124 @@ monitor: enable: true # -- Minutes between re-pinging the pods interval: 1 +# -- The IDE applications this installation offers. +# +# This map is the single source of truth for three things that used to be +# configured separately and drifted apart: the AppDefinition custom resources, +# the app list the landing page shows, and the set of images preloaded onto +# every node. Adding a language is one entry here, not three edits in two +# repositories. +# +# Each key is the AppDefinition name. `image` is a repository without a tag - +# the tag comes from versions.ide (or the chart's appVersion), so a release +# moves every IDE image at once. An entry with a `landingPage` key is offered in +# the landing page drop-down; one without is deployable but hidden. +# @default -- (see details below) +appDefinitions: + # -- Applied to every app that does not state its own. Only the four scaling + # and sizing values genuinely differ between languages. + defaults: + uid: 101 + port: 3000 + mountPath: /home/project + timeout: 1440 + requestsCpu: 200m + requestsMemory: 500M + limitsMemory: 2400M + limitsCpu: "2" + minInstances: 0 + maxInstances: 1000 + downlinkLimit: 30000 + uplinkLimit: 30000 + imagePullPolicy: IfNotPresent + options: + dataBridgeEnabled: "true" + dataBridgePort: "16281" + # -- The applications. Key is the AppDefinition name. + apps: + java-17-latest: + image: eduide/java-17 + requestsCpu: 500m + limitsMemory: 3000M + # Java is the common case, so some instances are kept warm. + minInstances: 3 + landingPage: + label: Java 17 + java-17-templates-latest: + image: eduide/java-17-templates + requestsCpu: 500m + limitsMemory: 3000M + landingPage: + label: Java 17 (Templates) + c-latest: + image: eduide/c + landingPage: + label: C + c-templates-latest: + image: eduide/c-templates + landingPage: + label: C (Templates) + javascript-latest: + image: eduide/javascript + landingPage: + label: JavaScript + ocaml-latest: + image: eduide/ocaml + landingPage: + label: OCaml + python-latest: + image: eduide/python + landingPage: + label: Python + rust-latest: + image: eduide/rust + landingPage: + label: Rust + # -- Values to configure preloading of images on Kubernetes nodes. # @default -- (see details below) preloading: # -- Is image preloading enabled. enable: true - # -- Images to preload. Each item is either an image reference string or a map: + # -- Extra images to preload, on top of the ones derived automatically. + # + # Leave this empty. The chart preloads every appDefinitions.apps image, every + # sidecar image and the landing page image without being told, so the list + # cannot fall out of step with what the installation actually offers. It used + # to be written out by hand per environment and addressed by array index, + # which is how production ended up offering c-templates while preloading + # everything except c-templates. + # + # Each item is either an image reference string or a map: # `{ image: "...", args: ["--version"] }` to use the image entrypoint (distroless-friendly), # or `{ image: "...", command: [...], args: [...] }` for a full override. If only strings are used, # the chart runs `/bin/sh -c 'echo …; exit 0'` (shell required in the image). - # If the list is empty and demoApplication.install == true, demoApplication.name is automatically added. images: [] + # -- Set to false to preload only preloading.images and nothing derived. + deriveFromApps: true # -- Optional: Override the imagePullPolicy for the image preloading containers. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: +# -- The Gradle build cache and Maven proxy. Optional: nothing reaches it +# unless operator.enableBuildCaching or operator.enableDependencyCaching is also +# turned on, so enabling this alone deploys a cache with no clients. +sharedCache: + enabled: false + +# -- Reaps workspaces whose sessions are long gone. +garbageCollector: + enabled: true + image: + # The garbage collector has never been released with a version tag - GHCR + # holds only `latest`, `main` and per-commit SHAs. Its own chart defaults to + # `latest`, which would mean a release of this chart installs whatever was + # built most recently, and `helm upgrade` would see no diff when it changed. + # Pinned to the commit that `latest` pointed at on 2026-08-25 so a 2.0.0 + # install is reproducible. Replace this with a semver tag once the + # workspace-garbage-collector repo cuts a release. + tag: "599557839e5c5893eb0c20785dac671ae70f7e8a" + # -- Skip the check that eduide-cluster is installed on this cluster. # Only useful for rendering against a cluster that intentionally lacks it. skipPreflight: false diff --git a/docs/charts.md b/docs/charts.md index 90bcc8f..376e660 100644 --- a/docs/charts.md +++ b/docs/charts.md @@ -10,11 +10,11 @@ Two charts, released together with the same version. ```bash # once per cluster helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster \ - --version 1.0.0-rc0 -n eduide-system --create-namespace + --version 2.0.0 -n eduide-system --create-namespace # once per environment helm install eduide oci://ghcr.io/eduide/charts/eduide \ - --version 1.0.0-rc0 -n test1 -f my-values.yaml + --version 2.0.0 -n eduide-test1 -f my-values.yaml ``` ## Why two charts and not one diff --git a/scripts/test-app-consistency.sh b/scripts/test-app-consistency.sh new file mode 100755 index 0000000..220eed6 --- /dev/null +++ b/scripts/test-app-consistency.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# The three consumers of appDefinitions.apps must agree. +# +# An installation offers an app in three places: the AppDefinition custom +# resource that makes it deployable, the landing page entry that lets a student +# pick it, and the preloading DaemonSet that pulls its image onto every node. +# These used to be three hand-maintained lists in two repositories, addressed by +# array index. Production ended up offering c-templates while preloading +# everything except c-templates - students picking it waited for a cold +# multi-gigabyte pull. +# +# They are all derived from one map now. This asserts the derivation, so a +# template change cannot quietly reintroduce the skew. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CHART="$ROOT/charts/eduide" +FAILED=0 + +ok() { printf ' PASS %s\n' "$1"; } +bad() { printf ' FAIL %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' "$2"; FAILED=1; } + +render() { + helm template t "$CHART" --set skipPreflight=true --set demoApplication.install=false "$@" 2>/dev/null +} + +echo "=== app definitions, landing page and preloading agree ===" + +OUT=$(render) || { echo " chart does not render"; exit 1; } + +declared=$(yq -r '.appDefinitions.apps | keys | .[]' "$CHART/values.yaml" | sort) +defs=$(yq -r 'select(.kind=="AppDefinition") | .metadata.name' <<<"$OUT" | grep -v '^---$' | sort) + +if [[ "$declared" == "$defs" ]]; then + ok "$(wc -l <<<"$defs" | tr -d ' ') AppDefinitions, one per declared app" +else + bad "AppDefinitions do not match the declared apps" "$(diff <(echo "$declared") <(echo "$defs") | tr '\n' ' ')" +fi + +# Every image an AppDefinition references, and every sidecar image, has to be +# on the node before a session starts. +app_images=$(yq -r 'select(.kind=="AppDefinition") | .spec.image, (.spec.sidecars // [])[].image' <<<"$OUT" \ + | grep -v '^---$' | sort -u) +preloaded=$(yq -r 'select(.kind=="DaemonSet" and .metadata.name=="image-preloading") + | .spec.template.spec.initContainers[].image' <<<"$OUT" | grep -v '^---$' | sort -u) + +missing=$(comm -23 <(echo "$app_images") <(echo "$preloaded")) +if [[ -z "$missing" ]]; then + ok "every app and sidecar image is preloaded" +else + bad "images offered but not preloaded" "$(tr '\n' ' ' <<<"$missing")" +fi + +# The landing page must not advertise an app that was never deployed. +offered=$(yq -r 'select(.kind=="ConfigMap" and (.metadata.name|test("landing"))) + | .data | to_entries[0].value' <<<"$OUT" \ + | sed -n '/additionalApps: \[/,/^ *\],/p' \ + | grep -oE 'serviceAuthToken: "[^"]+"' | sed 's/.*"\(.*\)"/\1/' | sort -u) +if [[ -z "$offered" ]]; then + bad "landing page offers no apps at all" "" +else + orphan=$(comm -23 <(echo "$offered") <(echo "$defs")) + if [[ -z "$orphan" ]]; then + ok "$(wc -l <<<"$offered" | tr -d ' ') apps offered, all of them deployed" + else + bad "landing page offers apps with no AppDefinition" "$(tr '\n' ' ' <<<"$orphan")" + fi +fi + +# The landing page's own default app has to be one of them, or the page loads +# pointing at nothing. +default=$(yq -r '.landingPage.appDefinition' "$CHART/values.yaml") +if grep -qx "$default" <<<"$defs"; then + ok "landingPage.appDefinition '$default' exists" +else + bad "landingPage.appDefinition '$default' has no AppDefinition" "" +fi + +echo +echo "=== versions ===" + +# A release is `helm install --version X` with no overrides. Every EduIDE image +# must then carry a tag that release published, not a floating one. +APP_VERSION=$(yq -r '.appVersion' "$CHART/Chart.yaml") +floating=$(grep -oE "ghcr\.io/eduide/[^ \";']+" <<<"$OUT" | sort -u | grep -E ':(latest|main|next)$') +if [[ -z "$floating" ]]; then + ok "no floating tags in a default render" +else + bad "default render uses floating tags" "$(tr '\n' ' ' <<<"$floating")" +fi + +ide=$(grep -oE "ghcr\.io/eduide/eduide/[^ \";']+" <<<"$OUT" | sort -u) +wrong=$(grep -v ":${APP_VERSION}\$" <<<"$ide") +if [[ -z "$wrong" ]]; then + ok "every IDE image defaults to appVersion ${APP_VERSION}" +else + bad "IDE images not on appVersion ${APP_VERSION}" "$(tr '\n' ' ' <<<"$wrong")" +fi + +# Cloud and landing page release on their own cadence, so they must be +# overridable without touching anything else. +for pair in "cloud:eduide-cloud/operator" "landingPage:eduidec-landing-page"; do + key="${pair%%:*}"; repo="${pair##*:}" + got=$(render --set "versions.${key}=9.9.9" | grep -oE "ghcr\.io/eduide/${repo}:[^ \";']+" | sort -u) + if [[ "$got" == "ghcr.io/eduide/${repo}:9.9.9" ]]; then + ok "versions.${key} overrides ${repo} independently" + else + bad "versions.${key} did not take effect" "$got" + fi +done + +echo +[[ $FAILED -eq 0 ]] && echo "ALL PASS" || echo "SOME FAILED" +exit $FAILED From b36e9a0579d86fe18171c83b5fa89e13c7089007 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 17:13:19 +0200 Subject: [PATCH 06/14] Take over the cluster half: shared Gateway, monitoring, sidecar RBAC eduide-cluster held the CRDs, webhook, ClusterRoles and issuers, but the shared Gateway and the PodMonitors were still chart source in EduIDE-deployment, and bootstrap only ever installed the Gateway. A fresh cluster therefore never got the CRDs that every tenant deploy checks for, so it could not have been brought up at all. All of it moves here, which is what makes the deployment repo chart-free: templates/gateway/ the shared Gateway, GatewayClass, EnvoyProxy, ACME issuer, wildcard secret templates/monitoring/ two PodMonitors and two Grafana dashboards The Gateway moves to eduide-system with the rest of the cluster-scoped resources. Its listeners and the PodMonitors' watched namespaces are both left empty here and derived by the bootstrap workflow from the environments on the cluster - the monitoring list was hand-written and had gone stale, still naming theia and theia-staging, so some environments were scraped and others silently were not. A PodMonitor rendered with no namespaces now fails the template rather than watching nothing. The tenant chart picks up operator-sidecar-pod-restart, the one template the old umbrella carried. It was not part of any release, so the deploy workflow adopted it with an inline kubectl annotate on every run. It is a normal template now. Two bugs found while checking nothing was lost: The dependency aliases were camelCase, and an alias becomes .Chart.Name inside the subchart. eduide-shared-cache builds resource names from it, so the release contained `sharedCache-redis` - which helm renders happily and the API server rejects, because RFC 1123 names are lowercase. Both dependencies now use their real names. test-app-consistency.sh checks every rendered name. Enabling the cache made helm warn that it could not overwrite eduide-shared-cache.gateway.parentRefs: both charts have a `gateway:` table and this one's parentRefs is a list where the subchart's is a map. The subchart wins and its routes stay off, which is fine, but it was being relied on silently - now asserted, so it fails if coalescing ever starts propagating gateway.enabled down and publishes routes for hostnames nobody configured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- AGENTS.md | 27 + charts/eduide-cluster/README.md | 28 + .../templates/gateway/certificates.yaml | 20 + .../templates/gateway/envoyproxy.yaml | 17 + .../gateway/gateway-acme-issuer.yaml | 21 + .../templates/gateway/gateway.yaml | 44 + .../templates/gateway/gatewayclass.yaml | 20 + .../templates/gateway/wildcard-secret.yaml | 11 + .../monitoring/dashboard-session-startup.yaml | 318 +++++ .../monitoring/dashboard-theiacloud.yaml | 1129 +++++++++++++++++ .../monitoring/podmonitor-service.yaml | 23 + .../monitoring/podmonitor-sessions.yaml | 28 + charts/eduide-cluster/values.yaml | 71 ++ charts/eduide/Chart.lock | 4 +- charts/eduide/Chart.yaml | 10 +- charts/eduide/README.md | 10 +- .../operator-sidecar-pod-restart-role.yaml | 43 + charts/eduide/values.yaml | 6 +- scripts/test-app-consistency.sh | 40 + 19 files changed, 1856 insertions(+), 14 deletions(-) create mode 100644 charts/eduide-cluster/templates/gateway/certificates.yaml create mode 100644 charts/eduide-cluster/templates/gateway/envoyproxy.yaml create mode 100644 charts/eduide-cluster/templates/gateway/gateway-acme-issuer.yaml create mode 100644 charts/eduide-cluster/templates/gateway/gateway.yaml create mode 100644 charts/eduide-cluster/templates/gateway/gatewayclass.yaml create mode 100644 charts/eduide-cluster/templates/gateway/wildcard-secret.yaml create mode 100644 charts/eduide-cluster/templates/monitoring/dashboard-session-startup.yaml create mode 100644 charts/eduide-cluster/templates/monitoring/dashboard-theiacloud.yaml create mode 100644 charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml create mode 100644 charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml create mode 100644 charts/eduide/templates/operator-sidecar-pod-restart-role.yaml diff --git a/AGENTS.md b/AGENTS.md index 79b696d..99d232e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,3 +124,30 @@ per-commit SHAs - and its own chart defaults to `latest`. A released chart must not install whatever was built most recently, and `helm upgrade` would see no diff when it changed. `garbageCollector.image.tag` therefore pins a commit SHA. Replace it with a semver tag when that repo starts releasing. + +## Dependency aliases must be lowercase + +An `alias:` becomes `.Chart.Name` inside the subchart, and charts build resource +names and label values from it. `alias: sharedCache` rendered +`sharedCache-redis`, which the API server rejects - RFC 1123 names are lowercase +only, and `helm template` renders it happily. The dependencies are declared +under their real names for that reason, so the values keys are +`eduide-shared-cache:` and `theia-workspace-garbage-collector:`. +`test-app-consistency.sh` checks every rendered name. + +## One expected warning from `helm template` + +``` +warning: cannot overwrite table with non table for eduide-shared-cache.gateway.parentRefs +``` + +Both this chart and the cache subchart have a top-level `gateway:` table, and +this chart's `parentRefs` is a list where the subchart's is a map. Helm +coalesces the parent's table down and says so. The subchart's map wins, its +HTTPRoutes stay off, and only this chart's three routes render - +`test-app-consistency.sh` asserts exactly that, so the day the behaviour +changes it fails rather than quietly publishing routes for hostnames nobody +configured. + +Do not parse `helm template` output with `2>&1`. That warning lands in the YAML +and anything downstream reads it as a broken document. diff --git a/charts/eduide-cluster/README.md b/charts/eduide-cluster/README.md index 2fa3a00..35fccbb 100644 --- a/charts/eduide-cluster/README.md +++ b/charts/eduide-cluster/README.md @@ -17,6 +17,24 @@ cert-manager issuers. Install once per cluster, before any eduide release. | conversion.certMountPath | string | `"/etc/webhook/certs"` | The location of where the certificates are mounted into the container (needs to match with application.properties) | | conversion.certReloadPeriod | int | `604800` | The certificate reload period in seconds | | conversion.image | string | `"theiacloud/theia-cloud-conversion-webhook:1.2.0-next"` | The image of the webhook container | +| envoyProxy.annotations | object | `{}` | | +| envoyProxy.create | bool | `false` | | +| envoyProxy.labels | object | `{}` | | +| envoyProxy.name | string | `"theia-shared-gateway"` | | +| envoyProxy.namespace | string | `"envoy-gateway-system"` | | +| envoyProxy.spec | object | `{}` | | +| gateway | object | `{"addresses":[],"allowedRoutes":{"namespaces":{"from":"All"}},"annotations":{},"className":"envoy","labels":{},"listeners":[],"name":"theia-shared-gateway","namespace":"eduide-system"}` | ------------------------------------------------------------------------ | +| gatewayAcmeIssuer.email | string | `""` | | +| gatewayAcmeIssuer.enabled | bool | `false` | | +| gatewayAcmeIssuer.name | string | `"letsencrypt-prod-gateway"` | | +| gatewayAcmeIssuer.privateKeySecretName | string | `"letsencrypt-prod-gateway-priv-key"` | | +| gatewayAcmeIssuer.server | string | `"https://acme-v02.api.letsencrypt.org/directory"` | | +| gatewayAcmeIssuer.serviceType | string | `"ClusterIP"` | | +| gatewayClass.annotations | object | `{}` | | +| gatewayClass.controllerName | string | `"gateway.envoyproxy.io/gatewayclass-controller"` | | +| gatewayClass.create | bool | `false` | | +| gatewayClass.labels | object | `{}` | | +| gatewayClass.parametersRef | object | `{}` | | | issuer.email | string | `"mmorlock@example.com"` | email used to issue let's encrypt certificates | | issuerca.enable | bool | `true` | whether to install the CA certificate signer | | issuerca.name | string | `"theia-cloud-ca-certificate-signer"` | name for the issuer preparing a self signed CA certificate | @@ -24,8 +42,18 @@ cert-manager issuers. Install once per cluster, before any eduide release. | issuerprod.name | string | `"letsencrypt-prod"` | name for the let's encrypt production cluster issuer | | issuerprod.solvers | list | `[]` | ACME solver list for cert-manager (required when `issuerprod.enable=true`) | | issuerstaging.name | string | `"theia-cloud-selfsigned-issuer"` | name for the self signed cluster issuer | +| managedCertificates.certificates | list | `[]` | | +| managedCertificates.enabled | bool | `false` | | +| managedCertificates.issuerRef.kind | string | `"ClusterIssuer"` | | +| managedCertificates.issuerRef.name | string | `"letsencrypt-prod"` | | +| monitoring | object | `{"dashboardNamespace":"cattle-dashboards","enabled":true,"namespace":"cattle-monitoring-system","sessionNamespaces":[],"targetNamespaces":[]}` | ------------------------------------------------------------------------ | | operatorrole.name | string | `"operator-api-access"` | name for the operator's cluster role | | servicerole.name | string | `"service-api-access"` | name for the services' cluster role | +| wildcardTLSSecret.certificate | string | `""` | | +| wildcardTLSSecret.create | bool | `false` | | +| wildcardTLSSecret.key | string | `""` | | +| wildcardTLSSecret.name | string | `"static-theia-cert"` | | +| wildcardTLSSecret.namespace | string | `"eduide-system"` | | ---------------------------------------------- Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/eduide-cluster/templates/gateway/certificates.yaml b/charts/eduide-cluster/templates/gateway/certificates.yaml new file mode 100644 index 0000000..3711b4c --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/certificates.yaml @@ -0,0 +1,20 @@ +{{- if and .Values.managedCertificates.enabled (gt (len .Values.managedCertificates.certificates) 0) }} +{{- range $cert := .Values.managedCertificates.certificates }} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $cert.name }} + namespace: {{ default $.Values.gateway.namespace $cert.namespace }} +spec: + secretName: {{ $cert.secretName }} + commonName: {{ $cert.hostname | quote }} + dnsNames: + - {{ $cert.hostname | quote }} + issuerRef: + kind: {{ $.Values.managedCertificates.issuerRef.kind }} + name: {{ $.Values.managedCertificates.issuerRef.name }} + privateKey: + rotationPolicy: Never +{{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/envoyproxy.yaml b/charts/eduide-cluster/templates/gateway/envoyproxy.yaml new file mode 100644 index 0000000..8671814 --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/envoyproxy.yaml @@ -0,0 +1,17 @@ +{{- if .Values.envoyProxy.create }} +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyProxy +metadata: + name: {{ .Values.envoyProxy.name }} + namespace: {{ .Values.envoyProxy.namespace }} + {{- with .Values.envoyProxy.labels }} + labels: +{{ toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.envoyProxy.annotations }} + annotations: +{{ toYaml . | nindent 4 }} + {{- end }} +spec: +{{ toYaml .Values.envoyProxy.spec | nindent 2 }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/gateway-acme-issuer.yaml b/charts/eduide-cluster/templates/gateway/gateway-acme-issuer.yaml new file mode 100644 index 0000000..0988c54 --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/gateway-acme-issuer.yaml @@ -0,0 +1,21 @@ +{{- if .Values.gatewayAcmeIssuer.enabled }} +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: {{ .Values.gatewayAcmeIssuer.name }} +spec: + acme: + server: {{ .Values.gatewayAcmeIssuer.server | quote }} + email: {{ required "gatewayAcmeIssuer.email must be set when gatewayAcmeIssuer.enabled=true" .Values.gatewayAcmeIssuer.email | quote }} + privateKeySecretRef: + name: {{ .Values.gatewayAcmeIssuer.privateKeySecretName | quote }} + solvers: + - http01: + gatewayHTTPRoute: + serviceType: {{ .Values.gatewayAcmeIssuer.serviceType | quote }} + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: {{ .Values.gateway.name }} + namespace: {{ .Values.gateway.namespace }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/gateway.yaml b/charts/eduide-cluster/templates/gateway/gateway.yaml new file mode 100644 index 0000000..5af343c --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/gateway.yaml @@ -0,0 +1,44 @@ +{{- if gt (len .Values.gateway.listeners) 0 }} +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: {{ .Values.gateway.name }} + namespace: {{ .Values.gateway.namespace }} + {{- with .Values.gateway.labels }} + labels: +{{ toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gateway.annotations }} + annotations: +{{ toYaml . | nindent 4 }} + {{- end }} +spec: + gatewayClassName: {{ .Values.gateway.className }} + {{- with .Values.gateway.addresses }} + addresses: + {{- range $address := . }} + - type: IPAddress + value: {{ $address | quote }} + {{- end }} + {{- end }} + listeners: + {{- range $listener := .Values.gateway.listeners }} + {{- $protocol := default "HTTPS" $listener.protocol }} + {{- $port := default (ternary 443 80 (eq $protocol "HTTPS")) $listener.port }} + - name: {{ $listener.name }} + protocol: {{ $protocol }} + port: {{ $port }} + {{- with $listener.hostname }} + hostname: {{ $listener.hostname | quote }} + {{- end }} + {{- if eq $protocol "HTTPS" }} + tls: + mode: Terminate + certificateRefs: + - kind: Secret + name: {{ $listener.tlsSecretName | quote }} + {{- end }} + allowedRoutes: +{{ toYaml (default $.Values.gateway.allowedRoutes $listener.allowedRoutes) | nindent 6 }} + {{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/gatewayclass.yaml b/charts/eduide-cluster/templates/gateway/gatewayclass.yaml new file mode 100644 index 0000000..d183ffb --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/gatewayclass.yaml @@ -0,0 +1,20 @@ +{{- if .Values.gatewayClass.create }} +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: {{ .Values.gateway.className }} + {{- with .Values.gatewayClass.labels }} + labels: +{{ toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gatewayClass.annotations }} + annotations: +{{ toYaml . | nindent 4 }} + {{- end }} +spec: + controllerName: {{ .Values.gatewayClass.controllerName }} + {{- with .Values.gatewayClass.parametersRef }} + parametersRef: +{{ toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/wildcard-secret.yaml b/charts/eduide-cluster/templates/gateway/wildcard-secret.yaml new file mode 100644 index 0000000..325ed6c --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/wildcard-secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.wildcardTLSSecret.create }} +apiVersion: v1 +kind: Secret +type: kubernetes.io/tls +metadata: + name: {{ .Values.wildcardTLSSecret.name }} + namespace: {{ .Values.wildcardTLSSecret.namespace }} +data: + tls.crt: {{ .Values.wildcardTLSSecret.certificate }} + tls.key: {{ .Values.wildcardTLSSecret.key }} +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/dashboard-session-startup.yaml b/charts/eduide-cluster/templates/monitoring/dashboard-session-startup.yaml new file mode 100644 index 0000000..3947a0d --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/dashboard-session-startup.yaml @@ -0,0 +1,318 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + grafana_dashboard: "1" + name: theia-cloud-dashboard-session-startup + namespace: {{ .Values.monitoring.dashboardNamespace }} +data: + session-startup.json: |- + { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 49, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "rate(application_application_theiacloud_session_startup_seconds_seconds_sum{namespace=\"$namespace\"}[5m]) \n/ \nrate(application_application_theiacloud_session_startup_seconds_seconds_count{namespace=\"$namespace\"}[5m])", + "instant": false, + "interval": "", + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Average Session Startup Seconds", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "application_application_theiacloud_session_startup_seconds_seconds{namespace=\"$namespace\", quantile=\"0.5\"}", + "instant": false, + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Median Session Startup Seconds", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "application_application_theiacloud_session_startup_seconds_seconds{namespace=\"$namespace\", quantile=\"0.95\"}", + "instant": false, + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "P95 Session Startup Seconds", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "application_application_theiacloud_session_startup_seconds_seconds{namespace=\"$namespace\", quantile=\"0.99\"}", + "instant": false, + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "P99 Session Startup Seconds", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "test1", "value": "test1" }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "definition": "label_values(application_application_theiacloud_session_startup_seconds_seconds_count,namespace)", + "hide": 0, + "includeAll": false, + "label": "Namespace", + "multi": false, + "name": "namespace", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(application_application_theiacloud_session_startup_seconds_seconds_count,namespace)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { "from": "now-1h", "to": "now" }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "Theia Cloud Session Startup Time", + "uid": "bf4ha4miogutcc", + "version": 8, + "weekStart": "" + } +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/dashboard-theiacloud.yaml b/charts/eduide-cluster/templates/monitoring/dashboard-theiacloud.yaml new file mode 100644 index 0000000..91e64cc --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/dashboard-theiacloud.yaml @@ -0,0 +1,1129 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + grafana_dashboard: "1" + name: theia-cloud-dashboard-overview + namespace: {{ .Values.monitoring.dashboardNamespace }} +data: + theiacloud.json: |- + { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualization to observe a Theia Cloud deployment", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 5, + "panels": [], + "title": "Theia Cloud", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Shows the number of running session pods.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "stepBefore", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "fieldMinMax": false, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "count(count by (pod) (container_cpu_usage_seconds_total{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!=\"POD\"}))", + "format": "time_series", + "instant": false, + "interval": "", + "legendFormat": "Theia Session Count", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Pod Count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "CPU usage of all Theia Cloud pods. This includes session pods and service pods (i.e. landing page, REST service, operator).\nAlso shows the total over all Theia Cloud pods.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(container_cpu_usage_seconds_total{container!=\"POD\",container!=\"\", job=\"kubelet\", metrics_path=\"/metrics/cadvisor\", namespace=\"$namespace\"}[1m])) by (pod)", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(container_cpu_usage_seconds_total{container!=\"POD\",container!=\"\", job=\"kubelet\", metrics_path=\"/metrics/cadvisor\", namespace=\"$namespace\"}[1m]))", + "hide": false, + "instant": false, + "legendFormat": "TOTAL", + "range": true, + "refId": "B" + } + ], + "title": "Theia Cloud CPU usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Theia Backend process memory consumption for each session pod", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 60000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "container_memory_working_set_bytes{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"}", + "instant": false, + "legendFormat": "{{ "{{" }}pod{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Backend Memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "CPU usage for Theia Backend processes for each session pod.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "rate(container_cpu_usage_seconds_total{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"}[1m])", + "instant": false, + "interval": "", + "legendFormat": "{{ "{{" }}pod{{ "}}" }}", + "range": true, + "refId": "Theia Backend CPU" + } + ], + "title": "Theia Session Backend CPU", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Average Theia Backend process memory consumption per app definition", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 60000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg by (container) (container_memory_working_set_bytes{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"})", + "instant": false, + "legendFormat": "{{ "{{" }}container{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Backend Memory Averages", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Average Theia Backend process CPU consumption per app definition", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 60000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg by (container) (rate(container_cpu_usage_seconds_total{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"}[1m]))", + "instant": false, + "legendFormat": "{{ "{{" }}container{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Backend CPU Averages", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 9, + "panels": [], + "title": "Nodes", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "CPU Usage ratio per node in the cluster", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "instance:node_cpu:ratio", + "instant": false, + "legendFormat": "Node {{ "{{" }}instance{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Nodes CPU Usage Ratio", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Memory Usage Ratio for each node in the cluster.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Nodes Memory Usage Ratio", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 6, + "panels": [], + "title": "Cluster", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Ratio of CPUs used across the whole cluster", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 35 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "cluster:node_cpu:ratio", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Cluster CPU Usage Ratio", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Memory usage across the whole cluster", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 35 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Cluster Memory Usage Ratio", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "theia", "value": "theia" }, + "hide": 0, + "includeAll": false, + "label": "Namespace", + "multi": false, + "name": "namespace", + "options": [ + { + "selected": false, + "text": "theia", + "value": "theia" + }, + { + "selected": false, + "text": "theia-staging", + "value": "theia-staging" + }, + { + "selected": true, + "text": "test1", + "value": "test1" + }, + { + "selected": false, + "text": "test2", + "value": "test2" + }, + { + "selected": false, + "text": "test3", + "value": "test3" + } + ], + "query": "theia,theia-staging,test1,test2,test3", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { "from": "now-3h", "to": "now" }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "Theia Cloud", + "uid": "bdrjgy1fv3d34b", + "version": 7, + "weekStart": "" + } +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml b/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml new file mode 100644 index 0000000..b7bd47a --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml @@ -0,0 +1,23 @@ +{{- if .Values.monitoring.enabled }} +{{- if not .Values.monitoring.targetNamespaces }} +{{- fail "monitoring.enabled is true but monitoring.targetNamespaces is empty: this PodMonitor would watch nothing" }} +{{- end }} +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: theia-cloud-service + namespace: {{ .Values.monitoring.namespace }} +spec: + selector: + matchLabels: + app: service + namespaceSelector: + matchNames: + {{- range .Values.monitoring.targetNamespaces }} + - {{ . }} + {{- end }} + podMetricsEndpoints: + - port: http + interval: 15s + path: /q/metrics +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml b/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml new file mode 100644 index 0000000..999240e --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml @@ -0,0 +1,28 @@ +{{- if .Values.monitoring.enabled }} +{{- if not .Values.monitoring.sessionNamespaces }} +{{- fail "monitoring.enabled is true but monitoring.sessionNamespaces is empty: this PodMonitor would watch nothing" }} +{{- end }} +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: theia-cloud-sessions + namespace: {{ .Values.monitoring.namespace }} +spec: + selector: + matchExpressions: + - key: app + operator: NotIn + values: + - conversion-webhook + - landing-page + - operator + - service + namespaceSelector: + matchNames: + {{- range .Values.monitoring.sessionNamespaces }} + - {{ . }} + {{- end }} + podMetricsEndpoints: + - port: application + interval: 15s +{{- end }} diff --git a/charts/eduide-cluster/values.yaml b/charts/eduide-cluster/values.yaml index 70bbfac..856248a 100644 --- a/charts/eduide-cluster/values.yaml +++ b/charts/eduide-cluster/values.yaml @@ -50,3 +50,74 @@ conversion: # -- The cluster issuer to use for the certificate clusterIssuer: theia-cloud-selfsigned-issuer + +# -------------------------------------------------------------------------- +# The shared Gateway. One per cluster, one listener set per environment. +# +# This was a chart of its own in EduIDE-deployment until 2.0.0. It belongs here: +# a Gateway is cluster-scoped, and keeping chart source in the deployment repo +# meant the cluster half of an install was not versioned with the rest. +# +# `gateway.listeners` is filled in by the Bootstrap cluster workflow, derived +# from the environments that claim this cluster. Do not write it by hand - that +# is what deployments/shared-gateway/values.yaml was, and adding an environment +# meant remembering to edit a second file. +# -------------------------------------------------------------------------- +gateway: + name: theia-shared-gateway + namespace: eduide-system + className: envoy + labels: {} + annotations: {} + addresses: [] + allowedRoutes: + namespaces: + from: All + listeners: [] +gatewayClass: + create: false + controllerName: gateway.envoyproxy.io/gatewayclass-controller + labels: {} + annotations: {} + parametersRef: {} +envoyProxy: + create: false + name: theia-shared-gateway + namespace: envoy-gateway-system + labels: {} + annotations: {} + spec: {} +managedCertificates: + enabled: false + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod + certificates: [] +gatewayAcmeIssuer: + enabled: false + name: letsencrypt-prod-gateway + email: '' + privateKeySecretName: letsencrypt-prod-gateway-priv-key + server: https://acme-v02.api.letsencrypt.org/directory + serviceType: ClusterIP +wildcardTLSSecret: + create: false + name: static-theia-cert + namespace: eduide-system + certificate: '' + key: '' + +# -------------------------------------------------------------------------- +# PodMonitors and Grafana dashboards for Rancher's monitoring stack. +# +# Cluster-scoped like the Gateway: the PodMonitors live in Rancher's namespace +# and name every namespace they watch. That list was hand-written and had gone +# stale - it still named `theia` and `theia-staging`, which no longer exist. +# Bootstrap derives it from the environments on this cluster instead. +# -------------------------------------------------------------------------- +monitoring: + enabled: true + namespace: cattle-monitoring-system + dashboardNamespace: cattle-dashboards + targetNamespaces: [] + sessionNamespaces: [] diff --git a/charts/eduide/Chart.lock b/charts/eduide/Chart.lock index a14486a..e5595da 100644 --- a/charts/eduide/Chart.lock +++ b/charts/eduide/Chart.lock @@ -5,5 +5,5 @@ dependencies: - name: theia-workspace-garbage-collector repository: oci://ghcr.io/eduide/charts version: 0.1.0 -digest: sha256:65d9bdb261ed364cfd8b3c15075a6fb2f0ab2605d7f1c03147be1b0c1e84a654 -generated: "2026-08-26T16:52:06.254801+02:00" +digest: sha256:b17d9558356c541259bf6d69b0d86c10e324bd7bc53ea54d2050c705d0f9b5b6 +generated: "2026-08-26T17:09:32.134187+02:00" diff --git a/charts/eduide/Chart.yaml b/charts/eduide/Chart.yaml index d54c2f1..ff76579 100644 --- a/charts/eduide/Chart.yaml +++ b/charts/eduide/Chart.yaml @@ -31,14 +31,16 @@ appVersion: "1.2.0" # replaces still pinned theia-shared-cache 0.3.1, months after the rename, which # is why the name is spelled out here with a current version. dependencies: + # No alias. An alias becomes .Chart.Name inside the subchart, and this one + # builds resource names and label values from it - a camelCase alias renders + # `sharedCache-redis`, which the API server rejects because RFC 1123 names are + # lowercase only. - name: eduide-shared-cache - alias: sharedCache version: "0.5.3" repository: "oci://ghcr.io/eduide/charts" - condition: sharedCache.enabled + condition: eduide-shared-cache.enabled - name: theia-workspace-garbage-collector - alias: garbageCollector version: "0.1.0" repository: "oci://ghcr.io/eduide/charts" - condition: garbageCollector.enabled + condition: theia-workspace-garbage-collector.enabled diff --git a/charts/eduide/README.md b/charts/eduide/README.md index 7c14306..89b3082 100644 --- a/charts/eduide/README.md +++ b/charts/eduide/README.md @@ -12,8 +12,8 @@ environment. Requires eduide-cluster to be installed on the cluster first. | Repository | Name | Version | |------------|------|---------| -| oci://ghcr.io/eduide/charts | sharedCache(eduide-shared-cache) | 0.5.3 | -| oci://ghcr.io/eduide/charts | garbageCollector(theia-workspace-garbage-collector) | 0.1.0 | +| oci://ghcr.io/eduide/charts | eduide-shared-cache | 0.5.3 | +| oci://ghcr.io/eduide/charts | theia-workspace-garbage-collector | 0.1.0 | ## Values @@ -36,7 +36,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | demoApplication.name | string | `"theiacloud/theia-cloud-demo:1.2.0-next"` | The name of docker image to be used | | demoApplication.pullSecret | string | `""` | the image pull secret. Leave empty if registry is public | | demoApplication.timeout | string | `"30"` | Limit in minutes | -| garbageCollector | object | `{"enabled":true,"image":{"tag":"599557839e5c5893eb0c20785dac671ae70f7e8a"}}` | Reaps workspaces whose sessions are long gone. | +| eduide-shared-cache | object | `{"enabled":false}` | The Gradle build cache and Maven proxy. Optional: nothing reaches it unless operator.enableBuildCaching or operator.enableDependencyCaching is also turned on, so enabling this alone deploys a cache with no clients. | | gateway | object | `{"className":"envoy","create":true,"enabled":true,"httpEnabled":false,"httpPort":80,"httpsPort":443,"instancesRouteName":"theia-cloud-demo-ws-route","instancesWildcardSecretNames":{},"name":"theia-cloud-gateway","parentRefs":[],"routes":{"enabled":true},"serviceRouteRequestTimeout":"60s","tls":true}` | Gateway API configuration (Envoy Gateway by default) | | gateway.className | string | `"envoy"` | GatewayClassName to use (Envoy Gateway default is typically "envoy") | | gateway.create | bool | `true` | Whether to render a Gateway resource in the release namespace. Set to false when using a centralized shared Gateway in another namespace. | @@ -46,7 +46,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | gateway.instancesRouteName | string | `"theia-cloud-demo-ws-route"` | Name of the HTTPRoute that is updated to publish new Theia application instances | | gateway.instancesWildcardSecretNames | object | `{}` | Additional wildcard hostnames and optional dedicated TLS secret names Only accepts wildcard hostnames that are configured in `hosts.allWildcardInstances`. | | gateway.name | string | `"theia-cloud-gateway"` | Name of the Gateway resource | -| gateway.parentRefs | list | `[]` | Optional explicit parentRefs for HTTPRoutes. If empty, routes attach to `gateway.name` in the same namespace. Example for a centralized shared gateway: parentRefs: - name: theia-shared-gateway namespace: gateway-system | +| gateway.parentRefs | list | `[]` | Optional explicit parentRefs for HTTPRoutes. If empty, routes attach to `gateway.name` in the same namespace. Example for a centralized shared gateway: parentRefs: - name: theia-shared-gateway namespace: eduide-system | | gateway.routes.enabled | bool | `true` | Whether to render HTTPRoute resources. | | gateway.serviceRouteRequestTimeout | string | `"60s"` | HTTPRoute request timeout for service-route (Envoy default can be 15s) | | gateway.tls | bool | `true` | Does Theia Cloud expect TLS connections (true) or is TLS terminated outside of Theia Cloud (false) | @@ -143,8 +143,8 @@ environment. Requires eduide-cluster to be installed on the cluster first. | service.sentry | object | (see details below) | Values related to Sentry on the service. | | service.sentry.enable | bool | `true` | Whether to set SENTRY_ENABLE=true in the service deployment. | | servicerole.name | string | `"service-api-access"` | | -| sharedCache | object | `{"enabled":false}` | The Gradle build cache and Maven proxy. Optional: nothing reaches it unless operator.enableBuildCaching or operator.enableDependencyCaching is also turned on, so enabling this alone deploys a cache with no clients. | | skipPreflight | bool | `false` | Skip the check that eduide-cluster is installed on this cluster. Only useful for rendering against a cluster that intentionally lacks it. | +| theia-workspace-garbage-collector | object | `{"enabled":true,"image":{"tag":"599557839e5c5893eb0c20785dac671ae70f7e8a"}}` | Reaps workspaces whose sessions are long gone. | | versions | object | (see details below) | Image versions, one per source repository. Every image the chart deploys derives its tag from one of these three, so a release is three numbers rather than nineteen image strings scattered across environment values files. | | versions.cloud | string | `"1.2.0"` | EduIDE-Cloud: the operator and the REST service. Released independently of the IDE images, so it carries its own version. | | versions.ide | string | `""` | The IDE images from the EduIDE repository (java-17, c, python, ...). Empty falls through to the chart's appVersion, which is what a release sets, so a plain `helm install --version 2.0.0` pins every IDE image to the tag that release published. | diff --git a/charts/eduide/templates/operator-sidecar-pod-restart-role.yaml b/charts/eduide/templates/operator-sidecar-pod-restart-role.yaml new file mode 100644 index 0000000..387a394 --- /dev/null +++ b/charts/eduide/templates/operator-sidecar-pod-restart-role.yaml @@ -0,0 +1,43 @@ +{{- /* +The operator restarts a session's sidecar pods when an AppDefinition's sidecar +set changes. It needs delete on pods in its own namespace to do that. + +This was the only template in the theia-cloud-combined umbrella. Because it was +not part of any release the umbrella pulled, the deploy workflow used to adopt +it with an inline `kubectl annotate meta.helm.sh/release-name=...` on every run. +It is a normal template now and that hack is gone. +*/ -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: operator-sidecar-pod-restart + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: operator +rules: + - apiGroups: [""] + resources: ["pods"] + # deletecollection is intentionally kept for batch sidecar-pod restart cleanup operations. + verbs: ["get", "list", "watch", "delete", "deletecollection"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: operator-sidecar-pod-restart + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: operator +subjects: + - kind: ServiceAccount + name: operator-api-service-account + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: operator-sidecar-pod-restart diff --git a/charts/eduide/values.yaml b/charts/eduide/values.yaml index 4bdcc7b..50cd852 100644 --- a/charts/eduide/values.yaml +++ b/charts/eduide/values.yaml @@ -415,7 +415,7 @@ gateway: # Example for a centralized shared gateway: # parentRefs: # - name: theia-shared-gateway - # namespace: gateway-system + # namespace: eduide-system parentRefs: [] # -- HTTPS listener port httpsPort: 443 @@ -550,11 +550,11 @@ preloading: # -- The Gradle build cache and Maven proxy. Optional: nothing reaches it # unless operator.enableBuildCaching or operator.enableDependencyCaching is also # turned on, so enabling this alone deploys a cache with no clients. -sharedCache: +eduide-shared-cache: enabled: false # -- Reaps workspaces whose sessions are long gone. -garbageCollector: +theia-workspace-garbage-collector: enabled: true image: # The garbage collector has never been released with a version tag - GHCR diff --git a/scripts/test-app-consistency.sh b/scripts/test-app-consistency.sh index 220eed6..1f76140 100755 --- a/scripts/test-app-consistency.sh +++ b/scripts/test-app-consistency.sh @@ -110,6 +110,46 @@ for pair in "cloud:eduide-cloud/operator" "landingPage:eduidec-landing-page"; do fi done +echo +echo "=== the dependency cache adds no routing ===" + +# The cache subchart has its own `gateway:` table, and so does this chart. Helm +# coalesces the parent's into it, which it announces as +# warning: cannot overwrite table with non table for +# eduide-shared-cache.gateway.parentRefs +# The subchart's map wins, so its HTTPRoutes stay off and only this chart's +# three routes render. That is the behaviour being relied on, so assert it: +# if coalescing ever starts propagating `gateway.enabled: true` down, the cache +# would publish routes for hostnames nobody configured. +with_cache=$(render --set eduide-shared-cache.enabled=true \ + | yq -r 'select(.kind=="HTTPRoute") | .metadata.name' 2>/dev/null | grep -vE '^(---|null)$' | sort) +without=$(render | yq -r 'select(.kind=="HTTPRoute") | .metadata.name' 2>/dev/null \ + | grep -vE '^(---|null)$' | sort) +if [[ "$with_cache" == "$without" ]]; then + ok "enabling the cache adds no HTTPRoute" +else + bad "the cache added routes" "$(comm -23 <(echo "$with_cache") <(echo "$without") | tr '\n' ' ')" +fi + +echo +echo "=== rendered names are valid Kubernetes names ===" + +# RFC 1123: lowercase alphanumerics and dashes. Easy to break from a values file +# without noticing, because helm template happily renders an invalid name and +# only the API server rejects it. A camelCase dependency alias did exactly this: +# it becomes .Chart.Name in the subchart, which built `sharedCache-redis`. +for extra in "" "--set eduide-shared-cache.enabled=true"; do + # shellcheck disable=SC2086 + names=$(render $extra | yq -r '.metadata.name' 2>/dev/null | grep -vE '^(---|null)$' | sort -u) + bad_names=$(grep -vE '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' <<<"$names" || true) + label="${extra:-defaults}" + if [[ -z "$bad_names" ]]; then + ok "$(wc -l <<<"$names" | tr -d ' ') names valid (${label})" + else + bad "invalid resource names (${label})" "$(tr '\n' ' ' <<<"$bad_names")" + fi +done + echo [[ $FAILED -eq 0 ]] && echo "ALL PASS" || echo "SOME FAILED" exit $FAILED From d1c26be63d1a417965b63979595011e15e90b0f7 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 17:28:13 +0200 Subject: [PATCH 07/14] Per-release monitoring toggle, and stop shipping a placeholder Keycloak monitoring.enabled, default true. The PodMonitor objects stay in eduide-cluster - they have to be created in Rancher's own namespace to be discovered, and one per tenant writing there would collide on names - so the flag decides whether the release's namespace is in the list they watch. bootstrap-cluster.yml reads it when deriving that list. Two things found while wiring it up. The preflight defines were never invoked from any template, so neither check had ever run. Including them from operator.yaml surfaced the second problem immediately: the cluster check keyed off .Release.IsInstall, which is true under `helm template` as well, so it failed every offline render - the render diff and CI included. It looks up the kube-system namespace first now: every cluster has one, so an empty result means there is nothing to talk to and nothing to check. The oauth2-proxy ConfigMaps render regardless of keycloak.enable, because the operator mounts them into every session pod by literal name. Left at the chart's defaults that means a live proxy pointed at https://keycloak.url/auth/realms/TheiaCloud, a host that does not exist, so sessions fail at the proxy rather than running unauthenticated - the worst of both outcomes and no clue why. The chart now refuses to render on the placeholder values. An installation with no identity provider yet sets keycloak.allowUnauthenticated: true and says so out loud. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- AGENTS.md | 24 +++++++++++++++ charts/eduide-cluster/README.md | 1 + charts/eduide-cluster/values.yaml | 3 ++ charts/eduide/README.md | 2 ++ charts/eduide/templates/_preflight.tpl | 42 +++++++++++++++++++++++--- charts/eduide/templates/operator.yaml | 2 ++ charts/eduide/values.yaml | 23 ++++++++++++++ scripts/test-app-consistency.sh | 3 +- 8 files changed, 94 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 99d232e..13d8ba9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,3 +151,27 @@ configured. Do not parse `helm template` output with `2>&1`. That warning lands in the YAML and anything downstream reads it as a broken document. + +## Preflight checks must stay silent offline + +`helm template`, the render diff and CI all run without a cluster. A preflight +that fires there breaks all three. `lookup` returns empty both offline and when +the thing is genuinely missing, so it cannot tell them apart alone - look up the +`kube-system` namespace first, and only check anything if that comes back. + +The earlier version keyed off `.Release.IsInstall`, which is true under +`helm template` too. It went unnoticed because the define was never invoked from +any template. Both are fixed; the includes are at the top of `operator.yaml`, +which every install renders. + +## The oauth2 ConfigMaps are not gated on keycloak.enable + +They cannot be: the operator mounts `oauth2-proxy-config`, `oauth2-templates` +and `oauth2-emails` into every session pod by literal name. So a chart left at +the default `keycloak.authUrl` ships a live proxy pointed at +`https://keycloak.url/auth/realms/TheiaCloud`, and sessions fail at the proxy +instead of running unauthenticated. + +`eduide.preflightKeycloak` refuses to render on the placeholder values. +An installation that genuinely has no identity provider yet sets +`keycloak.allowUnauthenticated: true`. diff --git a/charts/eduide-cluster/README.md b/charts/eduide-cluster/README.md index 35fccbb..4e55c9f 100644 --- a/charts/eduide-cluster/README.md +++ b/charts/eduide-cluster/README.md @@ -47,6 +47,7 @@ cert-manager issuers. Install once per cluster, before any eduide release. | managedCertificates.issuerRef.kind | string | `"ClusterIssuer"` | | | managedCertificates.issuerRef.name | string | `"letsencrypt-prod"` | | | monitoring | object | `{"dashboardNamespace":"cattle-dashboards","enabled":true,"namespace":"cattle-monitoring-system","sessionNamespaces":[],"targetNamespaces":[]}` | ------------------------------------------------------------------------ | +| monitoring.enabled | bool | `true` | Create the PodMonitors and dashboards. Set to false by `bootstrap-cluster.yml` when no environment on the cluster opts in, so an all-opted-out cluster is a supported state rather than a render failure. | | operatorrole.name | string | `"operator-api-access"` | name for the operator's cluster role | | servicerole.name | string | `"service-api-access"` | name for the services' cluster role | | wildcardTLSSecret.certificate | string | `""` | | diff --git a/charts/eduide-cluster/values.yaml b/charts/eduide-cluster/values.yaml index 856248a..381d266 100644 --- a/charts/eduide-cluster/values.yaml +++ b/charts/eduide-cluster/values.yaml @@ -116,6 +116,9 @@ wildcardTLSSecret: # Bootstrap derives it from the environments on this cluster instead. # -------------------------------------------------------------------------- monitoring: + # -- Create the PodMonitors and dashboards. Set to false by + # `bootstrap-cluster.yml` when no environment on the cluster opts in, so an + # all-opted-out cluster is a supported state rather than a render failure. enabled: true namespace: cattle-monitoring-system dashboardNamespace: cattle-dashboards diff --git a/charts/eduide/README.md b/charts/eduide/README.md index 89b3082..64d1aed 100644 --- a/charts/eduide/README.md +++ b/charts/eduide/README.md @@ -67,6 +67,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | imageRegistry | string | `"ghcr.io/eduide"` | The container registry every EduIDE image is pulled from. | | keycloak | object | (see details below) | Values related to Keycloak | | keycloak.adminGroup | string | `"theia-cloud/admin"` | The name of the Keycloak group identifying admin users who are allowed to access the service's admin endpoints. | +| keycloak.allowUnauthenticated | bool | `false` | Install even though the Keycloak values below are still the chart's placeholders. The oauth2-proxy ConfigMaps render regardless of `enable` (the operator mounts them into every session by literal name), so leaving the placeholders means a proxy pointed at a host that does not exist and sessions that fail rather than run unauthenticated. Only set this for an installation that is deliberately not exposed yet. | | keycloak.authUrl | string | `"https://keycloak.url/auth/"` | Key cloak auth URL. Only has to be specified when enable: true | | keycloak.clientId | string | `"theia-cloud"` | The client-id. Only has to be specified when enable: true | | keycloak.clientSecret | string | `"publicbutoauth2proxywantsasecret"` | The oaid client secret. In case you configure your keycloak client as confidential, then you may specifiy the secret here. If you stick with our default public client, you may leave below value. For public clients keycloak does not generate a client-secret, but in order to make oath2-proxy happy, we will pass a value | @@ -96,6 +97,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | monitor.activityTracker.enable | bool | `true` | Should the activityTracker module be enabled | | monitor.activityTracker.interval | int | `1` | Minutes between re-pinging the pods | | monitor.enable | bool | `true` | Should the monitor be enabled | +| monitoring | object | `{"enabled":true}` | Whether this installation is scraped by Prometheus and appears on the dashboards. The PodMonitor objects themselves are in `eduide-cluster`, not here. They have to be created in Rancher's own namespace to be discovered, and one PodMonitor per tenant writing into a shared namespace would collide on names. So the cluster chart owns the objects and this flag decides whether this release's namespace is in the list they watch - `bootstrap-cluster.yml` reads it when it derives that list. Turning it off means this environment stops being scraped. Nothing else about the release changes; `monitor.enable` below is a different thing entirely (the operator's own session activity tracker). | | oauth2Proxy | object | `{"cookieDomains":[],"sslInsecureSkipVerify":false,"whitelistDomains":[]}` | Values related to OAuth2 Proxy configuration | | oauth2Proxy.sslInsecureSkipVerify | bool | `false` | Whether OAuth2 Proxy skips TLS certificate verification of the OIDC provider (sets ssl_insecure_skip_verify). Defaults to false to enforce certificate validation. Set to true only when the provider uses a self-signed or otherwise untrusted certificate. | | operator | object | (see details below) | Values related to the operator | diff --git a/charts/eduide/templates/_preflight.tpl b/charts/eduide/templates/_preflight.tpl index 8192494..9eaa165 100644 --- a/charts/eduide/templates/_preflight.tpl +++ b/charts/eduide/templates/_preflight.tpl @@ -5,18 +5,50 @@ operator crash-looping because the AppDefinition CRD does not exist, which is a considerably worse way to find out. - lookup returns nothing under `helm template`, so this only fires against a - real cluster; rendering and diffing still work offline. + This has to stay silent offline, or it breaks `helm template`, the render + diff and CI. lookup returns empty in both cases - offline and genuinely + missing - so it cannot tell them apart on its own. Looking up the kube-system + namespace first can: every cluster has one, so an empty result means there is + no cluster to talk to and there is nothing to check. + + The earlier version keyed off .Release.IsInstall, which is true under + `helm template` as well, so it failed every offline render. It was also never + invoked from any template, which is the only reason that went unnoticed. */}} {{- define "eduide.preflight" -}} {{- if not .Values.skipPreflight }} {{- if .Capabilities.APIVersions.Has "theia.cloud/v1beta11" }} {{- /* CRDs are present, so the cluster chart is installed. */ -}} -{{- else if .Capabilities.APIVersions.Has "v1" }} -{{- $cm := lookup "v1" "ConfigMap" "eduide-system" "eduide-cluster-version" -}} -{{- if and (not $cm) (not (empty .Release.IsInstall)) }} +{{- else if (lookup "v1" "Namespace" "" "kube-system") }} +{{- /* A real cluster, and it does not have the CRDs. */ -}} +{{- if not (lookup "v1" "ConfigMap" "eduide-system" "eduide-cluster-version") }} {{- fail "eduide-cluster is not installed on this cluster. Run the Bootstrap cluster workflow first, or set skipPreflight=true if you know better." }} {{- end }} {{- end }} {{- end }} {{- end -}} + +{{/* + Refuse to install with the chart's placeholder Keycloak values. + + The oauth2-proxy ConfigMaps render unconditionally - the operator mounts them + into every session pod by literal name, so they are not gated on + keycloak.enable. With the defaults left in place that means a live oauth2 + proxy pointed at `https://keycloak.url/auth/realms/TheiaCloud`, a host that + does not exist. Sessions fail at the proxy rather than starting without + authentication, which is the worst of both outcomes and gives no clue why. + + An installation that genuinely has no identity provider yet sets + keycloak.allowUnauthenticated: true and says so out loud. +*/}} +{{- define "eduide.preflightKeycloak" -}} +{{- $kc := .Values.keycloak -}} +{{- if not .Values.gitea.enable }} +{{- $placeholder := or (eq ($kc.authUrl | toString) "https://keycloak.url/auth/") + (eq ($kc.realm | toString) "TheiaCloud") + (eq ($kc.clientId | toString) "theia-cloud") -}} +{{- if and $placeholder (not $kc.allowUnauthenticated) }} +{{- fail (printf "keycloak is left at the chart's placeholder values (authUrl=%s realm=%s clientId=%s). Configure them, or set keycloak.allowUnauthenticated=true to install without a working identity provider." $kc.authUrl $kc.realm $kc.clientId) }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/charts/eduide/templates/operator.yaml b/charts/eduide/templates/operator.yaml index 15b6da2..af64749 100644 --- a/charts/eduide/templates/operator.yaml +++ b/charts/eduide/templates/operator.yaml @@ -1,3 +1,5 @@ +{{- include "eduide.preflight" . -}} +{{- include "eduide.preflightKeycloak" . -}} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/charts/eduide/values.yaml b/charts/eduide/values.yaml index 50cd852..b02647a 100644 --- a/charts/eduide/values.yaml +++ b/charts/eduide/values.yaml @@ -203,6 +203,13 @@ landingPage: keycloak: # -- Whether keycloak authentication shall be used enable: false + # -- Install even though the Keycloak values below are still the chart's + # placeholders. The oauth2-proxy ConfigMaps render regardless of `enable` + # (the operator mounts them into every session by literal name), so leaving + # the placeholders means a proxy pointed at a host that does not exist and + # sessions that fail rather than run unauthenticated. Only set this for an + # installation that is deliberately not exposed yet. + allowUnauthenticated: false # -- The name of the Keycloak group identifying admin users who are allowed to access the service's admin endpoints. adminGroup: "theia-cloud/admin" # -- Key cloak auth URL. Only has to be specified when enable: true @@ -547,6 +554,22 @@ preloading: # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: +# -- Whether this installation is scraped by Prometheus and appears on the +# dashboards. +# +# The PodMonitor objects themselves are in `eduide-cluster`, not here. They have +# to be created in Rancher's own namespace to be discovered, and one PodMonitor +# per tenant writing into a shared namespace would collide on names. So the +# cluster chart owns the objects and this flag decides whether this release's +# namespace is in the list they watch - `bootstrap-cluster.yml` reads it when it +# derives that list. +# +# Turning it off means this environment stops being scraped. Nothing else about +# the release changes; `monitor.enable` below is a different thing entirely (the +# operator's own session activity tracker). +monitoring: + enabled: true + # -- The Gradle build cache and Maven proxy. Optional: nothing reaches it # unless operator.enableBuildCaching or operator.enableDependencyCaching is also # turned on, so enabling this alone deploys a cache with no clients. diff --git a/scripts/test-app-consistency.sh b/scripts/test-app-consistency.sh index 1f76140..7bd3307 100755 --- a/scripts/test-app-consistency.sh +++ b/scripts/test-app-consistency.sh @@ -22,7 +22,8 @@ ok() { printf ' PASS %s\n' "$1"; } bad() { printf ' FAIL %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' "$2"; FAILED=1; } render() { - helm template t "$CHART" --set skipPreflight=true --set demoApplication.install=false "$@" 2>/dev/null + helm template t "$CHART" --set skipPreflight=true --set demoApplication.install=false \ + --set keycloak.allowUnauthenticated=true "$@" 2>/dev/null } echo "=== app definitions, landing page and preloading agree ===" From 54fff297be35cf22c077a42b552c20d1aac5a9db Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 17:36:34 +0200 Subject: [PATCH 08/14] Refuse to render an HTTPS listener with no TLS secret It renders certificateRefs with an empty name. Nothing rejects that: the Gateway is accepted and simply never programs TLS for the hostname, so the first symptom is a browser connection failure against a Gateway that reports itself healthy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- charts/eduide-cluster/templates/gateway/gateway.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/charts/eduide-cluster/templates/gateway/gateway.yaml b/charts/eduide-cluster/templates/gateway/gateway.yaml index 5af343c..f5e9085 100644 --- a/charts/eduide-cluster/templates/gateway/gateway.yaml +++ b/charts/eduide-cluster/templates/gateway/gateway.yaml @@ -32,6 +32,14 @@ spec: hostname: {{ $listener.hostname | quote }} {{- end }} {{- if eq $protocol "HTTPS" }} + {{- /* + An HTTPS listener with no secret renders certificateRefs with an empty name. + Nothing rejects that: the Gateway is accepted and simply never programs TLS + for the hostname, so the first symptom is a connection failure in a browser. + */}} + {{- if not $listener.tlsSecretName }} + {{- fail (printf "gateway listener %q is HTTPS but has no tlsSecretName" $listener.name) }} + {{- end }} tls: mode: Terminate certificateRefs: From 84443560bb69356e3d2f6f30dd5bc5e19c103289 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 19:55:05 +0200 Subject: [PATCH 09/14] Fix the three ways CI was failing on this branch Dependencies are resolved rather than vendored, and charts/*/charts/ is gitignored, so a fresh checkout has none. Neither the kubeconform job nor render-envs.sh fetched them, so both failed the moment charts/eduide gained dependencies. Both build them now. The PodMonitors failed the render when their namespace list was empty. That was meant to catch a derivation bug, but it also broke `helm install eduide-cluster` with no values - the documented one-command install - and bootstrap already disables monitoring when no environment opts in. They skip instead, and test-deploy-logic.sh keeps the assertion where it belongs. render-envs.sh picked its layout from whatever the deployment checkout had. EduIDE-deployment's main still carries deployments/, whose values are keyed under `theia-cloud:` and mean nothing to the eduide chart, so the head render failed in a way that looked like a chart bug. The layout follows the chart generation now, and a mismatch says so and renders nothing rather than failing. It also supplies the two secrets the deploy would - constants, so no diff noise. Verified by reproducing all three jobs locally against a checkout with the dependencies stripped, which is what CI actually gets. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/ci.yml | 6 ++ .../monitoring/podmonitor-service.yaml | 12 ++- .../monitoring/podmonitor-sessions.yaml | 12 ++- scripts/render-envs.sh | 75 ++++++++++++++++--- 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7234365..5e711a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,7 +109,13 @@ jobs: failed=0 for chart in charts/*/; do echo "::group::kubeconform $(basename "$chart")" + # charts/*/charts/ is gitignored - dependencies are resolved, not + # vendored - so a fresh checkout has to fetch them before rendering. + if yq -e '.dependencies' "$chart/Chart.yaml" >/dev/null 2>&1; then + helm dependency build "$chart" >/dev/null 2>&1 || helm dependency update "$chart" >/dev/null + fi helm template test "$chart" \ + --set keycloak.allowUnauthenticated=true \ | kubeconform -strict -summary -ignore-missing-schemas -skip HTTPRoute "${SCHEMAS[@]}" || failed=1 echo "::endgroup::" done diff --git a/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml b/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml index b7bd47a..ee28236 100644 --- a/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml +++ b/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml @@ -1,7 +1,12 @@ {{- if .Values.monitoring.enabled }} -{{- if not .Values.monitoring.targetNamespaces }} -{{- fail "monitoring.enabled is true but monitoring.targetNamespaces is empty: this PodMonitor would watch nothing" }} -{{- end }} +{{- /* +A PodMonitor naming no namespaces watches nothing, so it is not rendered at all +rather than created as a no-op. The list is derived by bootstrap-cluster.yml +from the environments on the cluster; an empty one means every environment +opted out, which is a supported state. A bare `helm install eduide-cluster` +lands here too, and must not fail. +*/}} +{{- if .Values.monitoring.targetNamespaces }} apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: @@ -21,3 +26,4 @@ spec: interval: 15s path: /q/metrics {{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml b/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml index 999240e..d35e743 100644 --- a/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml +++ b/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml @@ -1,7 +1,12 @@ {{- if .Values.monitoring.enabled }} -{{- if not .Values.monitoring.sessionNamespaces }} -{{- fail "monitoring.enabled is true but monitoring.sessionNamespaces is empty: this PodMonitor would watch nothing" }} -{{- end }} +{{- /* +A PodMonitor naming no namespaces watches nothing, so it is not rendered at all +rather than created as a no-op. The list is derived by bootstrap-cluster.yml +from the environments on the cluster; an empty one means every environment +opted out, which is a supported state. A bare `helm install eduide-cluster` +lands here too, and must not fail. +*/}} +{{- if .Values.monitoring.sessionNamespaces }} apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: @@ -26,3 +31,4 @@ spec: - port: application interval: 15s {{- end }} +{{- end }} diff --git a/scripts/render-envs.sh b/scripts/render-envs.sh index c028782..f69df4e 100755 --- a/scripts/render-envs.sh +++ b/scripts/render-envs.sh @@ -13,9 +13,18 @@ # diffs the two trees. That diff answers the only question that matters when # changing a chart: "what will this actually do to production?" # -# Environment values live in EduIDE-deployment, nested under a `theia-cloud:` -# key of the umbrella chart's values, and use YAML anchors that only resolve in -# the context of the whole file - hence `explode(.)` before extracting. +# Environment values live in EduIDE-deployment, which has two layouts while the +# restructure lands: +# +# environments//values.yaml plain values for the eduide chart +# deployments//values.yaml the old umbrella's values, nested under a +# `theia-cloud:` key and using YAML anchors +# that only resolve within the whole file - +# hence `explode(.)` before extracting +# +# Whichever is present is used. Rendering the old layout against the new chart +# is meaningless (different value keys entirely), so the layout is chosen from +# the deployment checkout, not from the chart. # # NOTE: two templates call `lookup`, which returns empty under `helm template` # and would otherwise produce a false diff on every single PR. Both are masked @@ -29,13 +38,15 @@ CHARTS_ARG="${3:-}" if [[ -z "$DEPLOY" ]]; then for candidate in ../EduIDE-deployment ../../EduIDE-deployment ./EduIDE-deployment; do - [[ -d "$candidate/deployments" ]] && { DEPLOY="$candidate"; break; } + if [[ -d "$candidate/environments" || -d "$candidate/deployments" ]]; then + DEPLOY="$candidate"; break + fi done fi -[[ -d "${DEPLOY:-/nonexistent}/deployments" ]] || { +if [[ ! -d "${DEPLOY:-/nonexistent}/environments" && ! -d "${DEPLOY:-/nonexistent}/deployments" ]]; then echo "Could not find EduIDE-deployment. Pass its path as the second argument." >&2 exit 2 -} +fi if [[ -n "$CHARTS_ARG" ]]; then CHARTS_DIR="$(cd "$CHARTS_ARG" && pwd)" @@ -51,9 +62,35 @@ TENANT_CHART=eduide echo "No eduide or theia-cloud chart under $CHARTS_DIR" >&2 exit 2 } -echo "rendering from $CHARTS_DIR" + +# The layout follows the chart generation, not whatever the deployment checkout +# happens to have. The old values are keyed under `theia-cloud:` and mean +# nothing to the eduide chart, so rendering one against the other produces a +# failure that looks like a chart bug and is not one. +if [[ "$TENANT_CHART" == eduide ]]; then LAYOUT=environments; else LAYOUT=deployments; fi + +if [[ ! -d "$DEPLOY/$LAYOUT" ]]; then + # Expected while the restructure is in flight: the head chart is `eduide` but + # EduIDE-deployment's main branch still carries `deployments/`. There is + # nothing comparable, and saying so beats failing. + echo "::notice::$TENANT_CHART needs the $LAYOUT/ layout, which this EduIDE-deployment checkout does not have." + echo "::notice::Nothing to render. Merge the matching EduIDE-deployment PR, or point this at that branch." + mkdir -p "$OUT" + exit 0 +fi +echo "rendering from $CHARTS_DIR (chart $TENANT_CHART, $LAYOUT layout)" mkdir -p "$OUT" +# Dependencies are resolved, not vendored: charts/*/charts/ is gitignored, so a +# fresh checkout has none and `helm template` refuses to render. Cheap when +# there is nothing to fetch. +if [[ -f "$CHARTS_DIR/$TENANT_CHART/Chart.yaml" ]] \ + && yq -e '.dependencies' "$CHARTS_DIR/$TENANT_CHART/Chart.yaml" >/dev/null 2>&1; then + helm dependency build "$CHARTS_DIR/$TENANT_CHART" >/dev/null 2>&1 \ + || helm dependency update "$CHARTS_DIR/$TENANT_CHART" >/dev/null 2>&1 \ + || echo "warning: could not resolve dependencies for $TENANT_CHART" >&2 +fi + # --- MASKS --------------------------------------------------------------- # Lines whose value is nondeterministic under `helm template`. If you add a # `lookup` to a template, add it here too or render-diff becomes noise. @@ -72,22 +109,36 @@ mask() { } rendered=0 -for dir in "$DEPLOY"/deployments/*/; do +for dir in "$DEPLOY"/$LAYOUT/*/; do env_name="$(basename "$dir")" case "$env_name" in *shared-gateway*) continue ;; esac [[ -f "$dir/values.yaml" ]] || continue values="$(mktemp)" - yq -r 'explode(.) | ."theia-cloud"' "$dir/values.yaml" > "$values" - ns="$(yq -r '.hosts.configuration.landing // "default"' "$values")" + extra=() + if [[ "$LAYOUT" == environments ]]; then + # The deploy supplies these from the environment's GitHub secrets. Rendering + # needs them present, not correct - they are constants here, so they add no + # diff noise. + secrets="$(mktemp)" + printf 'keycloak:\n cookieSecret: render-only\nservice:\n adminApiToken: cmVuZGVyLW9ubHk=\n' > "$secrets" + extra+=(-f "$secrets") + # Plain values, plus the base every deploy applies first. + cp "$dir/values.yaml" "$values" + [[ -f "$DEPLOY/environments/_base.yaml" ]] && extra+=(-f "$DEPLOY/environments/_base.yaml") + ns="$(yq -r '.spec.namespace // "default"' "$dir/env.yaml" 2>/dev/null || echo default)" + else + yq -r 'explode(.) | ."theia-cloud"' "$dir/values.yaml" > "$values" + ns="$(yq -r '.hosts.configuration.landing // "default"' "$values")" + fi if ! helm template theia-cloud "$CHARTS_DIR/$TENANT_CHART" \ - -f "$values" --namespace "$ns" 2> "$OUT/$env_name.err" | mask > "$OUT/$env_name.yaml"; then + "${extra[@]}" -f "$values" --namespace "$ns" 2> "$OUT/$env_name.err" | mask > "$OUT/$env_name.yaml"; then echo "RENDER FAILED for $env_name:" >&2 cat "$OUT/$env_name.err" >&2 exit 1 fi - rm -f "$values" "$OUT/$env_name.err" + rm -f "$values" "$OUT/$env_name.err" "${secrets:-}" printf ' rendered %-45s %s resources\n' "$env_name" "$(grep -c '^kind:' "$OUT/$env_name.yaml" || true)" rendered=$((rendered + 1)) done From 74a394672d2932fb8cf03c5a41946b1d7629ed57 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 19:56:55 +0200 Subject: [PATCH 10/14] test-app-consistency.sh must resolve its own dependencies Third place with the same root cause: charts/*/charts/ is gitignored, so a fresh checkout has no dependencies and `helm template` refuses. The script only ever ran where someone had already run `helm dependency update` by hand, which was true locally and never true in CI. Verified against a checkout with the resolved dependencies and Chart.lock stripped, which is what the runner gets. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- scripts/test-app-consistency.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/test-app-consistency.sh b/scripts/test-app-consistency.sh index 7bd3307..41848f4 100755 --- a/scripts/test-app-consistency.sh +++ b/scripts/test-app-consistency.sh @@ -21,6 +21,16 @@ FAILED=0 ok() { printf ' PASS %s\n' "$1"; } bad() { printf ' FAIL %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' "$2"; FAILED=1; } +# charts/*/charts/ is gitignored - dependencies are resolved, not vendored - so +# a fresh checkout has none and `helm template` refuses outright. Resolve once +# up front rather than leaving this script only working where someone happened +# to have run `helm dependency update` by hand. +if yq -e '.dependencies' "$CHART/Chart.yaml" >/dev/null 2>&1; then + helm dependency build "$CHART" >/dev/null 2>&1 \ + || helm dependency update "$CHART" >/dev/null 2>&1 \ + || { echo " could not resolve dependencies for $CHART"; exit 1; } +fi + render() { helm template t "$CHART" --set skipPreflight=true --set demoApplication.install=false \ --set keycloak.allowUnauthenticated=true "$@" 2>/dev/null From f6affa87735faa551801a4fd627eddbcd3e260c6 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 19:58:33 +0200 Subject: [PATCH 11/14] Make PR preview chart versions valid semver `helm package` rejected 2.0.0.pr-24. Appending ".pr-N" only ever worked because every chart version was already a prerelease - 1.4.0-next.7.pr-24 parses, since the dot simply adds another prerelease identifier. A release version needs the hyphen that starts the prerelease part, so moving to a clean 2.0.0 broke it. Previews are now 2.0.0-pr.24.: valid semver, sorts below the release it previews so `helm upgrade` cannot pick one up by accident, and distinguishes successive pushes on the same PR. A version that is already a prerelease keeps appending, since it has its hyphen. Checked by running each form through `helm package`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/release.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d7c845..b75cdd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,26 @@ jobs: OWNER="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" OCI_PREFIX="oci://${OCI_REGISTRY}/${OWNER}/${OCI_REPOSITORY}" - PREVIEW_SUFFIX="pr-${PR_NUMBER}" mkdir -p dist + # A preview version has to be valid semver, and it has to sort below + # the release it previews so `helm upgrade` never picks one up by + # accident. + # + # Appending ".pr-24" only worked while every chart version was already + # a prerelease: 1.4.0-next.7.pr-24 parses, because the dot just adds + # another prerelease identifier. 2.0.0.pr-24 does not - a release + # version needs the hyphen that starts the prerelease part. Bumping to + # a clean 2.0.0 is what surfaced this. + # + # The sha keeps successive pushes on one PR distinguishable. + SHA7="$(git rev-parse --short=7 HEAD)" to_preview_version() { local base="$1" - printf '%s.%s' "$base" "$PREVIEW_SUFFIX" + case "$base" in + *-*) printf '%s.pr.%s.%s' "$base" "$PR_NUMBER" "$SHA7" ;; # already a prerelease + *) printf '%s-pr.%s.%s' "$base" "$PR_NUMBER" "$SHA7" ;; + esac } charts=( From fe1dcca326c6df6600416e70644073590d237523 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 20:02:40 +0200 Subject: [PATCH 12/14] One place to resolve chart dependencies, used by everything that renders charts/*/charts/ is gitignored, so a fresh checkout has no dependencies and helm template, helm package and helm dependency list all refuse outright. That was rediscovered four separate times today - the kubeconform job, render-envs.sh, test-app-consistency.sh and the PR preview publish - each fixed in isolation, and the release and release-train publishes would have been the fifth and sixth once they ran. scripts/resolve-deps.sh does it once. It prefers `helm dependency build` so a committed Chart.lock is honoured and the result is reproducible, falling back to update when the lock is stale or missing. Every call site uses it. Verified by running all four jobs against a checkout with charts/*/charts/ removed, which is what the runner gets. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/ci.yml | 6 +--- .github/workflows/release-train.yml | 1 + .github/workflows/release.yml | 4 +++ AGENTS.md | 12 ++++++++ scripts/render-envs.sh | 16 +++++----- scripts/resolve-deps.sh | 45 +++++++++++++++++++++++++++++ scripts/test-app-consistency.sh | 7 ++--- 7 files changed, 72 insertions(+), 19 deletions(-) create mode 100755 scripts/resolve-deps.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e711a3..05137b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,11 +109,7 @@ jobs: failed=0 for chart in charts/*/; do echo "::group::kubeconform $(basename "$chart")" - # charts/*/charts/ is gitignored - dependencies are resolved, not - # vendored - so a fresh checkout has to fetch them before rendering. - if yq -e '.dependencies' "$chart/Chart.yaml" >/dev/null 2>&1; then - helm dependency build "$chart" >/dev/null 2>&1 || helm dependency update "$chart" >/dev/null - fi + ./scripts/resolve-deps.sh "$chart" helm template test "$chart" \ --set keycloak.allowUnauthenticated=true \ | kubeconform -strict -summary -ignore-missing-schemas -skip HTTPRoute "${SCHEMAS[@]}" || failed=1 diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml index 86a9b87..a2b2cc2 100644 --- a/.github/workflows/release-train.yml +++ b/.github/workflows/release-train.yml @@ -229,6 +229,7 @@ jobs: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin mkdir -p dist for c in eduide-cluster eduide; do + ./scripts/resolve-deps.sh "charts/$c" helm package "charts/$c" --destination dist helm push "dist/${c}-${V}.tgz" oci://ghcr.io/eduide/charts done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b75cdd1..b1b0749 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,7 @@ jobs: continue fi + ./scripts/resolve-deps.sh "charts/${chart}" helm package "charts/${chart}" --destination dist helm push "${artifact}" "${OCI_PREFIX}" helm show chart "${ref}" --version "${version}" >/dev/null @@ -142,6 +143,9 @@ jobs: sed -i "s/^version: .*/version: ${preview_version}/" "${work_dir}/Chart.yaml" + # The copy under dist/ needs its own dependencies: helm package + # reads charts/ from the directory it is given, not from the source. + ./scripts/resolve-deps.sh "$work_dir" helm package "$work_dir" --destination dist helm push "${artifact}" "${OCI_PREFIX}" helm show chart "${ref}" --version "${preview_version}" >/dev/null diff --git a/AGENTS.md b/AGENTS.md index 13d8ba9..3337314 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,18 @@ configured. Do not parse `helm template` output with `2>&1`. That warning lands in the YAML and anything downstream reads it as a broken document. +## Always resolve dependencies before touching a chart + +`charts/*/charts/` is gitignored - dependencies are resolved, not vendored, +which is what stops Chart.lock, Chart.yaml and HEAD drifting apart the way they +used to. The cost is that a fresh checkout has none, and `helm template`, +`helm package` and `helm dependency list` all refuse outright rather than +degrading. + +That was rediscovered four times in one afternoon: the kubeconform job, +`render-envs.sh`, `test-app-consistency.sh` and the PR preview publish. Call +`scripts/resolve-deps.sh ` first; every caller does. + ## Preflight checks must stay silent offline `helm template`, the render diff and CI all run without a cluster. A preflight diff --git a/scripts/render-envs.sh b/scripts/render-envs.sh index f69df4e..bcc509f 100755 --- a/scripts/render-envs.sh +++ b/scripts/render-envs.sh @@ -81,15 +81,13 @@ fi echo "rendering from $CHARTS_DIR (chart $TENANT_CHART, $LAYOUT layout)" mkdir -p "$OUT" -# Dependencies are resolved, not vendored: charts/*/charts/ is gitignored, so a -# fresh checkout has none and `helm template` refuses to render. Cheap when -# there is nothing to fetch. -if [[ -f "$CHARTS_DIR/$TENANT_CHART/Chart.yaml" ]] \ - && yq -e '.dependencies' "$CHARTS_DIR/$TENANT_CHART/Chart.yaml" >/dev/null 2>&1; then - helm dependency build "$CHARTS_DIR/$TENANT_CHART" >/dev/null 2>&1 \ - || helm dependency update "$CHARTS_DIR/$TENANT_CHART" >/dev/null 2>&1 \ - || echo "warning: could not resolve dependencies for $TENANT_CHART" >&2 -fi +# charts/*/charts/ is gitignored, so a fresh checkout has no dependencies and +# `helm template` refuses to render. render-diff renders two chart trees, so +# both need resolving; the base tree may predate the dependencies entirely, +# which is why a failure there is not fatal. +"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-deps.sh" \ + "$CHARTS_DIR/$TENANT_CHART" >/dev/null 2>&1 \ + || echo "warning: could not resolve dependencies for $CHARTS_DIR/$TENANT_CHART" >&2 # --- MASKS --------------------------------------------------------------- # Lines whose value is nondeterministic under `helm template`. If you add a diff --git a/scripts/resolve-deps.sh b/scripts/resolve-deps.sh new file mode 100755 index 0000000..25fb399 --- /dev/null +++ b/scripts/resolve-deps.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Fetch a chart's dependencies, if it has any. +# +# ./scripts/resolve-deps.sh charts/eduide +# ./scripts/resolve-deps.sh # every chart under charts/ +# +# charts/*/charts/ is gitignored: dependencies are resolved, not vendored, which +# is what keeps Chart.lock, Chart.yaml and HEAD from drifting apart the way they +# did before. The cost is that every command touching a chart on a fresh +# checkout has to fetch them first, and `helm template`, `helm package` and +# `helm dependency list` all refuse outright when they are missing. +# +# That was rediscovered four times in one afternoon - kubeconform, render-envs, +# test-app-consistency and the PR preview publish - so it lives in one place now +# and every caller uses it. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +resolve_one() { + local chart="$1" + [[ -f "$chart/Chart.yaml" ]] || return 0 + yq -e '.dependencies' "$chart/Chart.yaml" >/dev/null 2>&1 || return 0 + + # build honours Chart.lock and is reproducible; update re-resolves and is the + # fallback when the lock is stale or absent. + if helm dependency build "$chart" >/dev/null 2>&1; then + echo " resolved $(basename "$chart") (from Chart.lock)" + elif helm dependency update "$chart" >/dev/null 2>&1; then + echo " resolved $(basename "$chart") (re-resolved)" + else + echo " FAILED to resolve dependencies for $chart" >&2 + helm dependency build "$chart" 2>&1 | tail -3 >&2 + return 1 + fi +} + +failed=0 +if [[ $# -gt 0 ]]; then + for c in "$@"; do resolve_one "$c" || failed=1; done +else + for c in "$ROOT"/charts/*/; do resolve_one "${c%/}" || failed=1; done +fi +exit $failed diff --git a/scripts/test-app-consistency.sh b/scripts/test-app-consistency.sh index 41848f4..f103b7a 100755 --- a/scripts/test-app-consistency.sh +++ b/scripts/test-app-consistency.sh @@ -25,11 +25,8 @@ bad() { printf ' FAIL %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' " # a fresh checkout has none and `helm template` refuses outright. Resolve once # up front rather than leaving this script only working where someone happened # to have run `helm dependency update` by hand. -if yq -e '.dependencies' "$CHART/Chart.yaml" >/dev/null 2>&1; then - helm dependency build "$CHART" >/dev/null 2>&1 \ - || helm dependency update "$CHART" >/dev/null 2>&1 \ - || { echo " could not resolve dependencies for $CHART"; exit 1; } -fi +"$(dirname "${BASH_SOURCE[0]}")/resolve-deps.sh" "$CHART" >/dev/null || { + echo "could not resolve chart dependencies" >&2; exit 1; } render() { helm template t "$CHART" --set skipPreflight=true --set demoApplication.install=false \ From bcc1c083bb678bbf14409304968c1858ce9efe7e Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 23:04:13 +0200 Subject: [PATCH 13/14] Restore the landing page's build systems and hidden apps Deploying test3 against a real cluster surfaced these. The theia-cloud-combined umbrella carried them as CHART defaults, not as environment values, so deriving the new configuration from the environment files never saw them: java-17-templates-latest buildSystems Maven, Gradle c-templates-latest buildSystems Bazel, Make java-17-latest visible: false c-latest visible: false Without the build systems a -templates image offers no build-system picker, which is the entire reason those images exist. Without the visible flags the plain images appear in the drop-down alongside their -templates counterparts, which is why they were hidden in the first place. Labels go back to the spellings the landing page has always shown. Verified on test3: the rendered config.js serves both build-system lists and both visible flags. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- charts/eduide/README.md | 2 +- charts/eduide/values.yaml | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/charts/eduide/README.md b/charts/eduide/README.md index 64d1aed..3493279 100644 --- a/charts/eduide/README.md +++ b/charts/eduide/README.md @@ -23,7 +23,7 @@ environment. Requires eduide-cluster to be installed on the cluster first. | app.id | Deprecated | `"asdfghjkl"` | The app id which is used in the communication between website and REST-API as a spam migitation. This id is public. Please choose an random generated string. Use service.authToken instead. | | app.name | string | `"Theia Blueprint"` | The name of the application that may be displayed e.g. on the landing pages | | appDefinitions | object | (see details below) | The IDE applications this installation offers. This map is the single source of truth for three things that used to be configured separately and drifted apart: the AppDefinition custom resources, the app list the landing page shows, and the set of images preloaded onto every node. Adding a language is one entry here, not three edits in two repositories. Each key is the AppDefinition name. `image` is a repository without a tag - the tag comes from versions.ide (or the chart's appVersion), so a release moves every IDE image at once. An entry with a `landingPage` key is offered in the landing page drop-down; one without is deployable but hidden. | -| appDefinitions.apps | object | `{"c-latest":{"image":"eduide/c","landingPage":{"label":"C"}},"c-templates-latest":{"image":"eduide/c-templates","landingPage":{"label":"C (Templates)"}},"java-17-latest":{"image":"eduide/java-17","landingPage":{"label":"Java 17"},"limitsMemory":"3000M","minInstances":3,"requestsCpu":"500m"},"java-17-templates-latest":{"image":"eduide/java-17-templates","landingPage":{"label":"Java 17 (Templates)"},"limitsMemory":"3000M","requestsCpu":"500m"},"javascript-latest":{"image":"eduide/javascript","landingPage":{"label":"JavaScript"}},"ocaml-latest":{"image":"eduide/ocaml","landingPage":{"label":"OCaml"}},"python-latest":{"image":"eduide/python","landingPage":{"label":"Python"}},"rust-latest":{"image":"eduide/rust","landingPage":{"label":"Rust"}}}` | The applications. Key is the AppDefinition name. | +| appDefinitions.apps | object | `{"c-latest":{"image":"eduide/c","landingPage":{"label":"C","visible":false}},"c-templates-latest":{"image":"eduide/c-templates","landingPage":{"buildSystems":[{"id":"bazel","label":"Bazel"},{"id":"make","label":"Make"}],"label":"C"}},"java-17-latest":{"image":"eduide/java-17","landingPage":{"label":"Java 17","visible":false},"limitsMemory":"3000M","minInstances":3,"requestsCpu":"500m"},"java-17-templates-latest":{"image":"eduide/java-17-templates","landingPage":{"buildSystems":[{"id":"maven","label":"Maven"},{"id":"gradle","label":"Gradle"}],"label":"Java 17"},"limitsMemory":"3000M","requestsCpu":"500m"},"javascript-latest":{"image":"eduide/javascript","landingPage":{"label":"Javascript"}},"ocaml-latest":{"image":"eduide/ocaml","landingPage":{"label":"Ocaml"}},"python-latest":{"image":"eduide/python","landingPage":{"label":"Python"}},"rust-latest":{"image":"eduide/rust","landingPage":{"label":"Rust"}}}` | The applications. Key is the AppDefinition name. | | appDefinitions.defaults | object | `{"downlinkLimit":30000,"imagePullPolicy":"IfNotPresent","limitsCpu":"2","limitsMemory":"2400M","maxInstances":1000,"minInstances":0,"mountPath":"/home/project","options":{"dataBridgeEnabled":"true","dataBridgePort":"16281"},"port":3000,"requestsCpu":"200m","requestsMemory":"500M","timeout":1440,"uid":101,"uplinkLimit":30000}` | Applied to every app that does not state its own. Only the four scaling and sizing values genuinely differ between languages. | | demoApplication | object | (see details below) | Information about the demo application to be installed | | demoApplication.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the main application's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | diff --git a/charts/eduide/values.yaml b/charts/eduide/values.yaml index b02647a..eb98195 100644 --- a/charts/eduide/values.yaml +++ b/charts/eduide/values.yaml @@ -498,28 +498,40 @@ appDefinitions: minInstances: 3 landingPage: label: Java 17 + # Deployable and startable by name, but not offered in the drop-down - + # the -templates variant is what students pick. + visible: false java-17-templates-latest: image: eduide/java-17-templates requestsCpu: 500m limitsMemory: 3000M landingPage: - label: Java 17 (Templates) + label: Java 17 + # A -templates image exists to offer a build-system choice. Dropping + # this list is what makes the picker disappear from the landing page. + buildSystems: + - { id: maven, label: Maven } + - { id: gradle, label: Gradle } c-latest: image: eduide/c landingPage: label: C + visible: false c-templates-latest: image: eduide/c-templates landingPage: - label: C (Templates) + label: C + buildSystems: + - { id: bazel, label: Bazel } + - { id: make, label: Make } javascript-latest: image: eduide/javascript landingPage: - label: JavaScript + label: Javascript ocaml-latest: image: eduide/ocaml landingPage: - label: OCaml + label: Ocaml python-latest: image: eduide/python landingPage: From d43a3bcb612d83255a85ecb5114f6b0c81a0243e Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 26 Aug 2026 23:32:20 +0200 Subject: [PATCH 14/14] Let a managed certificate cover more than one hostname The template took a single `hostname` per Certificate, but every real installation uses one certificate covering every environment on the cluster - so the values that matter could not be expressed and the certificate was maintained by hand instead. test3 spent 184 days on one that covered test1, test2 and staging but not itself. `dnsNames` takes the list; `hostname` stays as the single-name shorthand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- charts/eduide-cluster/README.md | 2 +- .../templates/gateway/certificates.yaml | 21 +++++++++++++++++-- charts/eduide-cluster/values.yaml | 5 +++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/charts/eduide-cluster/README.md b/charts/eduide-cluster/README.md index 4e55c9f..4474757 100644 --- a/charts/eduide-cluster/README.md +++ b/charts/eduide-cluster/README.md @@ -42,7 +42,7 @@ cert-manager issuers. Install once per cluster, before any eduide release. | issuerprod.name | string | `"letsencrypt-prod"` | name for the let's encrypt production cluster issuer | | issuerprod.solvers | list | `[]` | ACME solver list for cert-manager (required when `issuerprod.enable=true`) | | issuerstaging.name | string | `"theia-cloud-selfsigned-issuer"` | name for the self signed cluster issuer | -| managedCertificates.certificates | list | `[]` | | +| managedCertificates.certificates | list | `[]` | Each entry takes either `hostname` (one name) or `dnsNames` (a list). `bootstrap-cluster.yml` fills this in from the environments on the cluster, so a new environment gets its certificate names without a second edit - which is how test3 ran for 184 days on a certificate that only covered test1, test2 and staging. | | managedCertificates.enabled | bool | `false` | | | managedCertificates.issuerRef.kind | string | `"ClusterIssuer"` | | | managedCertificates.issuerRef.name | string | `"letsencrypt-prod"` | | diff --git a/charts/eduide-cluster/templates/gateway/certificates.yaml b/charts/eduide-cluster/templates/gateway/certificates.yaml index 3711b4c..3e169b1 100644 --- a/charts/eduide-cluster/templates/gateway/certificates.yaml +++ b/charts/eduide-cluster/templates/gateway/certificates.yaml @@ -1,5 +1,20 @@ {{- if and .Values.managedCertificates.enabled (gt (len .Values.managedCertificates.certificates) 0) }} {{- range $cert := .Values.managedCertificates.certificates }} +{{- /* +A certificate may cover one hostname or many. The real installations use one +certificate per cluster covering every environment's landing, service and +instance host, so `dnsNames` is the useful shape and `hostname` is kept as the +single-name shorthand. + +Only names that actually have a listener can be included: cert-manager solves +HTTP-01 by serving a token on port 80 for each name, and a name with no listener +answers 404 and leaves the whole order pending - which blocks the certificate +for every other name on it too. +*/}} +{{- $names := $cert.dnsNames | default (list $cert.hostname) }} +{{- if not $names }} +{{- fail (printf "managedCertificates.certificates %q has neither hostname nor dnsNames" $cert.name) }} +{{- end }} --- apiVersion: cert-manager.io/v1 kind: Certificate @@ -8,9 +23,11 @@ metadata: namespace: {{ default $.Values.gateway.namespace $cert.namespace }} spec: secretName: {{ $cert.secretName }} - commonName: {{ $cert.hostname | quote }} + commonName: {{ (first $names) | quote }} dnsNames: - - {{ $cert.hostname | quote }} + {{- range $names }} + - {{ . | quote }} + {{- end }} issuerRef: kind: {{ $.Values.managedCertificates.issuerRef.kind }} name: {{ $.Values.managedCertificates.issuerRef.name }} diff --git a/charts/eduide-cluster/values.yaml b/charts/eduide-cluster/values.yaml index 381d266..7437860 100644 --- a/charts/eduide-cluster/values.yaml +++ b/charts/eduide-cluster/values.yaml @@ -92,6 +92,11 @@ managedCertificates: issuerRef: kind: ClusterIssuer name: letsencrypt-prod + # -- Each entry takes either `hostname` (one name) or `dnsNames` (a list). + # `bootstrap-cluster.yml` fills this in from the environments on the cluster, + # so a new environment gets its certificate names without a second edit - + # which is how test3 ran for 184 days on a certificate that only covered + # test1, test2 and staging. certificates: [] gatewayAcmeIssuer: enabled: false